Skip to content

Build voxel grids about 1.5x faster, with identical output - #33

Merged
AdamDHines merged 2 commits into
EventLAB-Team:mainfrom
Tobias-Fischer:speed-up-voxel
Sep 3, 2026
Merged

Build voxel grids about 1.5x faster, with identical output#33
AdamDHines merged 2 commits into
EventLAB-Team:mainfrom
Tobias-Fischer:speed-up-voxel

Conversation

@Tobias-Fischer

Copy link
Copy Markdown
Contributor

The camera runs made the voxel kernel the thing worth looking at: on a
saturated EVK4 it took 211 ms of a 50 ms window and was 80 % of loop time, so
the capture path was never what limited that loop.

Almost all of the cost turned out to be one line. Each event called both
floor() and ceil() and compared the results, which is two saturating
float-to-int conversions per event where one will do. age is filtered to
[0, window_ms] immediately above, so position lands in [0, bins - 1] and
is never negative --- and for a non-negative float as usize is the floor.
The fraction then carries everything the second call was for: it is zero
exactly when floor and ceil agreed, and when it is not, the upper bin is the
next one up, which is in range because a non-integer position is strictly below
bins - 1.

Measured on 2.8 M events over a 1280x720 grid, best of seven, old and new
kernels A/B'd in one process:

bins=1    61.0 ms -> 40.4 ms   1.51x
bins=5    96.8 ms -> 65.3 ms   1.48x
bins=9   109.0 ms -> 71.0 ms   1.54x

Output is bit-identical at every bin count --- the arithmetic is untouched, and
only the way the bin index is extracted from it changed. That was the point of
keeping the change this small: three other rewrites were tried and each of them
moved results in the last bits for less benefit than this one gives for none.

What the same benchmark says not to bother with, since the measurements cost
nothing to record and save someone repeating them:

  • Bounds checks are free here. Replacing both indexed writes with
    get_unchecked_mut came out slower than the safe version, inside noise.
    The scatter is waiting on memory, not on checks.
  • Accumulating bin-minor and transposing once at the end --- so an event's
    two contributions share a cache line instead of sitting plane_len apart
    --- was 15 % slower. The transpose costs more than the locality wins.
  • Walking the four columns by index instead of through stream.iter() was
    slower too; the iterator optimises better than manual indexing does.
  • Folding the divide into a constant and filtering ages in integer ticks is
    worth ~12 % on its own but nothing once truncation is in, and it does move
    the last bits. Not taken.

Rayon over per-thread accumulators reached a further 1.24x on two cores, but it
changes summation order (3.8e-5 on a cell here) and wants sizing on real
hardware, so it is left out of this change.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01Vj722xDQa64yshAdG3seYg

Tobias-Fischer and others added 2 commits September 3, 2026 00:06
The camera runs made the voxel kernel the thing worth looking at: on a
saturated EVK4 it took 211 ms of a 50 ms window and was 80 % of loop time, so
the capture path was never what limited that loop.

Almost all of the cost turned out to be one line. Each event called both
`floor()` and `ceil()` and compared the results, which is two saturating
float-to-int conversions per event where one will do. `age` is filtered to
`[0, window_ms]` immediately above, so `position` lands in `[0, bins - 1]` and
is never negative --- and for a non-negative float `as usize` *is* the floor.
The fraction then carries everything the second call was for: it is zero
exactly when floor and ceil agreed, and when it is not, the upper bin is the
next one up, which is in range because a non-integer position is strictly below
`bins - 1`.

Measured on 2.8 M events over a 1280x720 grid, best of seven, old and new
kernels A/B'd in one process:

    bins=1    61.0 ms -> 40.4 ms   1.51x
    bins=5    96.8 ms -> 65.3 ms   1.48x
    bins=9   109.0 ms -> 71.0 ms   1.54x

Output is bit-identical at every bin count --- the arithmetic is untouched, and
only the way the bin index is extracted from it changed. That was the point of
keeping the change this small: three other rewrites were tried and each of them
moved results in the last bits for less benefit than this one gives for none.

What the same benchmark says not to bother with, since the measurements cost
nothing to record and save someone repeating them:

  - Bounds checks are free here. Replacing both indexed writes with
    `get_unchecked_mut` came out slower than the safe version, inside noise.
    The scatter is waiting on memory, not on checks.
  - Accumulating bin-minor and transposing once at the end --- so an event's
    two contributions share a cache line instead of sitting `plane_len` apart
    --- was 15 % *slower*. The transpose costs more than the locality wins.
  - Walking the four columns by index instead of through `stream.iter()` was
    slower too; the iterator optimises better than manual indexing does.
  - Folding the divide into a constant and filtering ages in integer ticks is
    worth ~12 % on its own but nothing once truncation is in, and it does move
    the last bits. Not taken.

Rayon over per-thread accumulators reached a further 1.24x on two cores, but it
changes summation order (3.8e-5 on a cell here) and wants sizing on real
hardware, so it is left out of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vj722xDQa64yshAdG3seYg
A sensor emits many events at one tick --- an EVT3 vector word alone carries up
to twelve --- so the benchmark recording has 26 events per distinct timestamp.
The response `exp(-age/tau)` depends on the timestamp and nothing else, so
remembering the last one turns the most expensive operation in the loop from
once per event into once per tick.

    2.8 M events, 1280x720:  66.7 ms -> 50.2 ms   1.33x

Exact, and checked rather than assumed: the output checksum is unchanged
(b903401351dfabf7 before and after). The same input returns the same value, so
there is nothing approximated here --- unlike a faster `exp`, which is the
other way to attack this and moves every cell.

The rest of the representations were measured at the same operating point and
left alone, which is worth writing down so nobody repeats it:

    binary      3.2 ms   count  6.8 ms   polarity  16.3 ms   countmask 17.9 ms
    tencode    30.8 ms   tsurf 32.8 ms   voxel     46.8 ms   mcts      60.9 ms

`count` and `binary` are already at 400-870 Mev/s and have nothing in them but
a bounds check and an increment. `tsurf` looked like the obvious target ---
it is a time surface, so surely an `exp` per event --- but it already keeps the
latest timestamp per cell and exponentiates once per *cell* at the end, over
only the touched ones. Nothing to take.

`mcts` is the slow one after this and resisted both attempts. Memoising its
five window contributions the same way was worth nothing (57.3 -> 60.2 ms, i.e.
noise), so the five divisions are not what it spends its time on. Accumulating
channel-minor and transposing at the end, so an event's five writes share a
cache line instead of landing in five planes 3.7 MB apart, was far worse:
57.3 -> 106.0 ms. That is the same result the voxel work got from the same idea,
which makes twice that this layout change loses here. What mcts is doing is
waiting on memory for up to five scattered maxima per event across a 37 MB
buffer, and neither arithmetic nor layout moves that.

`reference_time` walking the stream through the `Event` iterator to take a max
is also not worth changing: replacing it with a pass over the timestamp column
alone measured inside noise for every representation that calls it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vj722xDQa64yshAdG3seYg
@AdamDHines
AdamDHines merged commit b7050e2 into EventLAB-Team:main Sep 3, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants