Skip to content

Scaling: Resource: compose an absolute location in one pass instead of re-deriving it per level - #1248

Merged
BioCam merged 2 commits into
PyLabRobot:mainfrom
BioCam:resource-absolute-location-one-pass
Sep 9, 2026
Merged

Scaling: Resource: compose an absolute location in one pass instead of re-deriving it per level#1248
BioCam merged 2 commits into
PyLabRobot:mainfrom
BioCam:resource-absolute-location-one-pass

Conversation

@BioCam

@BioCam BioCam commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Hi everyone,

In this PR, we continue improving our Resource modelling system's efficiency, on our way to a more scalable PLR:

Resource.get_absolute_location is now ~7x faster on a typical deck:
it collects the parent chain once and builds at most one rotation matrix per query, instead of rebuilding them at every level.
And the cost no longer grows meaningfully with nesting depth.

Results are bit-identical and fully backwards-compatible.
Where resources are rotated the improvement is smaller but never negative:
2.9x with one rotation high in the chain, 1.7x with every level turned.

before after faster
one query, a well 4 levels down 71 us 10 us 7x
every resource on a loaded deck, 996 of them 70 ms 9 ms 7.5x
building a loaded deck 643 ms 91 ms 7x

The final row highlights a reduced startup cost:
assign_child_resource computes positions as resources are placed, so a loaded STARlet deck builds in 91 ms rather than 643 ms.
-> your scripts will start faster - yeah 🎉💨

Problem

get_absolute_location recursed into its parent, and at each level built two rotation matrices - its own and its parent's - through get_absolute_rotation, which is itself a walk of the same chain.
A resource d levels down built 2d+1 matrices and added up the rotation chain d² times to answer one question.
Each matrix is twelve trigonometry calls and two 3x3 multiplications in pure Python, and on a deck where nothing is rotated, every one of those matrices is the identity matrix, so every multiplication through it returns the offset unchanged.

Changes

  • get_absolute_location collects the chain it is positioned through, then walks back down accumulating the position. A matrix is built where a resource turns and left as it was where it does not.
  • get_absolute_rotation is called once, at the top of that chain, rather than at every level.
  • While the accumulated rotation is zero no matrix is built at all: turning a vector by nothing returns the vector, so the offsets are added directly.

Behaviour: unchanged. Every coordinate is bit-for-bit what the recursion returned, not merely within a tolerance. get_absolute_rotation and Rotation.get_rotation_matrix are untouched; only how often they are called changed.

Scope:
the walk still stops at the first ancestor carrying no location, and the rotation is still taken from the whole tree.
The two methods have never referenced the same frame, and six callers rely on the location-less state; making them agree would move results and belongs in its own PR.

Not addressed / next steps:
repeated queries still recompute from scratch, and every intermediate result is still carried in a Coordinate that rounds three numbers on construction.
Caching absolute locations would need invalidation on every move, which is a much riskier change and worth measuring again only once this and the Coordinate work have landed (i.e. out of scope for this PR).

#1247 moves Rotation to internal quaternion storage, which makes get_rotation_matrix about 17x faster but Rotation.__add__ about 24x slower.
The old recursion leaned on exactly the operation #1247 makes slower, calling __add__ d² times per query, so against current main that PR measures 71 us to 87 us here.
By removing the d² complexity entirely, this PR therefore clears the way for #1247 to be purely an improvement:
in its primary intent, correct rotation composition, and in its side effect on compute cost.

Tests

Two added:
a rotated four-level chain checked against hand-computed coordinates at three anchor points, and a chain hanging from an ancestor that turns what it holds without positioning it.

Checked separately against the implementation it replaces on 670 761 absolute locations - every descendant of all 332 catalog resources constructible from a name alone, placed and rotated, across all 27 anchor combinations - and on 10 330 positions in random trees to depth 7.
Zero differences, at exact equality rather than a tolerance.

BioCam and others added 2 commits September 9, 2026 12:38
…ch rotation matrix once

`get_absolute_location` answered by recursing into its parent, and at every level of that
recursion built two rotation matrices - its own and its parent's - through `get_absolute_rotation`,
which is itself a walk of the same chain. A resource three levels down therefore built seven
matrices and composed the rotation chain nine times to answer one question, and each matrix costs
twelve trigonometry calls and two 3x3 multiplications in pure Python.

The chain is now collected once, topmost first, and walked back down accumulating the position. A
matrix is built where a resource turns and left alone where it does not, so a chain of squarely
placed resources builds one rather than one per level. `get_absolute_rotation` is called once, at
the top of the chain, instead of at every level.

Two subtleties are preserved deliberately. The walk stops at the first ancestor carrying no
location, because there is nothing there to add - `location` is optional throughout and six
callers rely on it, among them racks in an incubator and stacks in a BenchCel. The rotation,
however, is taken from the whole tree, since an ancestor can turn what hangs from it without
positioning it. The two have never referenced the same frame and this does not change that.

Behaviour is unchanged: `Resource.get_absolute_rotation` and `Rotation.get_rotation_matrix` are
untouched, and every returned coordinate is identical, not merely close. Checked against the
previous implementation on 670,761 absolute locations - all 332 catalog resources constructible
from a name alone, placed and rotated, every descendant, all 27 anchor combinations - and on 10,330
positions in random trees to depth 7 with rotations on all three axes, including chains hanging
from an ancestor that carries no location. Zero differences in both.

A well three levels down goes from 71 to 26 us, and a sweep of a loaded STARlet deck's 996
resources from 70 to 26 ms. Depth stops compounding: six levels deep goes from 105 to 35 us.

Tests cover a rotated three-level chain against hand-checked coordinates, and the walk against
level-by-level composition across rotation angles, three anchor combinations and a chain hanging
from a location-less ancestor. Five planted mistakes are caught by them: turning a location by the
child's matrix instead of its parent's, ignoring rotation above a location-less ancestor, skipping
the matrix where the chain does turn, stopping the walk early, and dropping the requested anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ain turns

Almost nothing on a deck is rotated, and turning a vector by nothing returns the vector: the
matrix is the identity, and `matrix_vector_multiply_3x3` against it hands back its input. Building
that matrix still costs twelve trigonometry calls and two 3x3 multiplications, and every
multiplication through it still allocates a `Coordinate`.

`get_absolute_location` now builds no matrix at all while the accumulated rotation is zero, and
adds the offsets directly. The moment a resource in the chain turns, the matrix is built and the
rest of the walk proceeds as before, so a rotated chain is unaffected beyond the levels above the
rotation.

`Rotation(0, 0, 0)` is the identity in both the current Euler representation and the quaternion
one proposed upstream, so the test is exact rather than approximate. Returned coordinates are
unchanged, again identically rather than closely: the same 670,761 catalog positions and 10,330
synthetic ones, zero differences.

A well three levels down goes from 26 to 10 us and a loaded STARlet deck's 996 resources from 26
to 9 ms, taking the pair of changes to 71 -> 10 us and 70 -> 9 ms. Nine fewer `Coordinate` objects
are allocated per query as a side effect, and twenty-seven fewer roundings; the rest of those
remain, since every intermediate result is still carried in a `Coordinate`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BioCam
BioCam requested a review from a team as a code owner September 9, 2026 11:40
@BioCam
BioCam merged commit e3f7492 into PyLabRobot:main Sep 9, 2026
22 checks passed
@BioCam
BioCam deleted the resource-absolute-location-one-pass branch September 9, 2026 12:46
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.

1 participant