Skip to content

Speed up serialization and remove the quadratic metadata scans - #74

Merged
chrislupp merged 8 commits into
developfrom
feature/efficiency
Aug 29, 2026
Merged

chrislupp merged 8 commits into
developfrom
feature/efficiency

Conversation

@chrislupp

Copy link
Copy Markdown
Collaborator

Performance work on the existing API. No .proto changes, no wire format changes, no submodule move — verified by running the new code against a server and client built from 510d3f8, in both directions.

Results

before after
250k-element round trip 121 ms 3.1 ms
100-variable gradient call 23.0 ms 14.6 ms
declare 2,000 variables 684 ms 6.7 ms
apply shapes to 1,000 dynamic variables 922 ms 120 ms
encode 200k doubles (per direction) 27.4 ms 0.15 ms
decode 200k doubles (per direction) 7.9 ms 0.11 ms

Commits

Each is independently revertable.

  • 17b6b0e — read and write Array.data through the packed wire buffer instead of protobuf's repeated double API, which boxes every element as a Python float. A packed repeated double is a tag, a length, and a contiguous block of little-endian doubles, which is byte for byte what NumPy already holds, so the emitted bytes are identical. Decoding stays on the protobuf container below 64 elements, where re-serializing the message to reach its payload costs more than it saves.
  • d3e8ebepreallocate_partials and _recover_partials rescanned the whole variable metadata list twice per declared partial, on every gradient call. Now indexed by name. The duplicated Jacobian block shape rule moved to utils.get_partials_shape().
  • 7a750f7get_chunk_indices returns the single-chunk case directly rather than deriving it through two NumPy array constructions.
  • 4357357 — default num_double 1,000 → 100,000. With the encoding cost gone, a stream's runtime is set by its message count, at ~64 µs per message per direction. A chunk is ~780 KiB against gRPC's 4 MiB ceiling. Default only; StreamOptions is negotiated as before.
  • 7a27e5d — the four add_* methods checked for duplicates by scanning the metadata list, making discipline declaration quadratic in its own size. Now a set of declared (type, name) pairs.
  • 9b5b854SetVariableShapes and send_variable_shapes resolved each shape by scanning the list, twice for an implicit output. Now indexed by type and name.
  • 8efcb16 — docs site npm advisories: 42 (2 critical, 26 high) → 17. See the caveat below.
  • 6392a2f — changelog consistency.

Where the floor is now

Everything remaining is gRPC's per-message cost. A bare echo servicer with no discipline logic costs 32.3 ms for 250 messages; Philote's own work is ~5 µs per message. So for bulk arrays you cut message count via chunk size, but a discipline with many small variables is bounded at ~64 µs per variable per direction. Reducing that means packing multiple variables into one message, which needs a protocol change and is deliberately out of scope here.

Caveats

  • 17 npm advisories remain, all image-size via @docusaurus/mdx-loader. Its advisory range is * — every published version is affected, so there is nothing to upgrade to. All are build-time; the deployed site is static. Docusaurus stays at 3.9.2: 3.10.2 left the audit count unchanged and breaks the build, since the site sets future.v4 and 3.10 requires the @docusaurus/faster package.
  • One unit test changed, test_discipline_client.py::test_init, which pinned the old num_double default.
  • Not addressed: both gradient RPCs do for jac, value in jac.items(), rebinding the loop variable over the dict being iterated. It works only because the items view is bound before the first rebind. Pre-existing, out of scope, tracked upstream as Loop variable shadows the dict being iterated in both gradient RPCs #71.

Testing

309 tests pass. New coverage: 23 tests for the encoding helpers asserting byte identity against messages built the ordinary way; 4 for get_partials_shape; chunk boundary and empty cases for get_chunk_indices; and two behaviours the duplicate-check rewrite could have broken and nothing tested — that an input and an output may share a name, and that _clear_data permits redeclaration.

Beyond the suite: cross-version interop against a 510d3f8 worktree in both directions, exact values for a 5000-element array, a scalar, and partials; a differential check of get_chunk_indices against its previous derivation across all 660 combinations of num_values 0–59 × chunk_size 1–11; and npm ci followed by npm run build from a clean install, since the docs deploy and release workflows depend on both.

The protobuf Python API converts every element of a repeated double field to
and from a boxed Python float. For bulk arrays that dominates: moving 200k
doubles cost 27.4 ms to encode and 7.9 ms to decode, per direction, against
about 2 ms for the gRPC round trip carrying them.

None of that work is required by the format. A packed repeated double is
encoded on the wire as a tag, a length, and a contiguous buffer of
little-endian doubles, which is byte for byte what NumPy already holds. So
this reads and writes that buffer directly:

  - set_array_data builds the Array with its metadata, then merges in a
    hand-framed field-6 blob, which protobuf stores contiguously
  - get_array_data re-serializes the message and reads the packed payload
    with np.frombuffer, which is two memory copies rather than N conversions

Output is byte-identical to what the protobuf API produces, verified across
sizes and metadata combinations and in both directions against messages built
the ordinary way. The protocol does not change and peers in any language are
unaffected: verified against a server built from 510d3f8, in both directions,
for a 5000-element array, a scalar, and partials.

Encode wins at every size, including a single element, so it is
unconditional. Decode has a fixed cost of about 1.9 us from the re-serialize,
so below 64 elements it stays on the protobuf container. The bound is already
in hand from the message's start and end indices, so the check is nearly free
and scalar-heavy disciplines are unaffected.

Coercion the protobuf API used to do for us is now explicit: values are cast
through little-endian float64, which pins byte order on a big-endian host and
stops a float32 array being written at half length or an integer array having
its bit pattern reinterpreted as doubles.

Measured, per direction for 200k doubles: encode 27.4 -> 0.15 ms, decode
7.9 -> 0.11 ms. End to end over the streaming transport, a 250k-element round
trip goes from 121 ms to 36 ms; the remainder is the default 1000-double
chunking, which this does not touch.
DisciplineServer.preallocate_partials and DisciplineClient._recover_partials
both looked up a variable's shape by scanning the entire variable metadata
list, building a throwaway list of every match and then indexing [0]. They did
it twice per declared partial, once for the function and once for the
variable, so the work was quadratic in the size of the discipline. It was paid
on every gradient call, not once at setup.

Both now build a {name: shape} index once per call and look up through it.

Measured with 100 variables and 100 declared partials: preallocate_partials
drops from 4.11 ms to 0.157 ms, and a full gradient round trip from 23.0 ms to
14.6 ms. The remaining time is the per-message gRPC cost, which is a function
of the message count rather than of anything computed here.

The two sites also carried separate copies of the rule for the shape of a
Jacobian block, including the edge cases where the function or the variable is
scalar. Since both copies had to be rewritten anyway, the rule now lives in
philote_mdo.utils.get_partials_shape, so the two cannot drift.
get_chunk_indices derived its answer by building an array of chunk starts with
np.arange, then an array of ends with np.append, then zipping the two. For a
variable that fits in a single chunk, which is every scalar and most small
arrays, that is two array constructions to produce one pair.

It now returns that pair directly. The guard is 0 < num_values <= chunk_size,
so an empty variable still yields no chunks rather than one empty chunk, which
is what the array path produced.

Verified identical to the previous derivation across every combination of
num_values in 0..59 and chunk_size in 1..11.

Across 100 scalar variables, 0.095 ms to 0.005 ms. Small on its own, but it is
on the path of every variable of every call.
With the per-element conversion cost gone from encoding, the runtime of a
stream is set by how many messages it carries, not by how large they are. A
bare gRPC echo servicer with no discipline logic costs about 64 microseconds
per message per direction, against roughly 5 microseconds of Philote's own
work to build and read one. So the default wants to be as large as the message
ceiling safely allows, and 1000 doubles was far below it.

For a 250k-element round trip, measured across the range:

  num_double=1000     250 msgs/dir    41.7 ms
  num_double=10000     25 msgs/dir     6.1 ms
  num_double=100000     3 msgs/dir     2.9 ms
  num_double=250000     1 msg /dir     3.0 ms

The curve is flat past 100k, so the ceiling is what picks the value rather
than the measurement. A 100k-double chunk serializes to about 780 KiB, a fifth
of gRPC's 4 MiB default limit, which leaves room both for message metadata and
for a peer that has lowered the limit. 500k would sit at 95% of it.

This is a default only. StreamOptions is negotiated over SetStreamOptions
exactly as before, both sides move together, and anyone who sets num_double
explicitly is unaffected. Variables smaller than a chunk are untouched, since
chunking only splits what exceeds the size.

The unit test that pinned the old default has been updated.
add_input, add_output, add_discrete_input and add_discrete_output each
rejected a duplicate declaration by scanning the entire metadata list for a
matching name and type. Declaring a discipline was therefore quadratic in its
own size, and every variable paid the cost of all the ones before it.

The declared (type, name) pairs now live in a set alongside the lists, so the
check is a hash lookup. _clear_data resets it with the rest of the metadata.

  variables   before     after
    100         2.0 ms    0.3 ms
    500        43.6 ms    1.7 ms
   1000       171.6 ms    3.5 ms
   2000       684.4 ms    6.7 ms

Setup runs once per client rather than once per call, so this does not show up
in a compute benchmark. It shows up when a large discipline starts, and for an
OpenMDAO group of remote components it is paid per component.

The rule the scan implemented is unchanged: the check is per variable type, so
an input and an output may still share a name, and the residual that an
implicit output implies still does not collide with it. Both of those, and
redeclaration after _clear_data, now have tests; the four duplicate-rejection
cases already did.
SetVariableShapes on the server and send_variable_shapes on the client both
resolved each incoming shape by scanning the whole variable metadata list, and
scanned it a second time to find the residual entry that an implicit output
implies. With one message per dynamic variable, that is quadratic in the size
of the discipline.

Both now build a {(type, name): variable} index once before consuming the
messages. setdefault keeps the first entry for a key, matching the break in
the loop it replaces.

  variables   before     after
    100        20.4 ms   12.5 ms
    250        82.0 ms   30.9 ms
    500       264.1 ms   61.5 ms
   1000       922.4 ms  119.9 ms

What remains is the per-message gRPC cost of the stream itself, which is now
linear in the number of dynamic variables.

The server's two rejections are unchanged and still ordered the same way: an
unknown variable is reported as not found, and a known one that was not
declared dynamic is rejected as not allowing dynamic shapes. Both already had
tests, as does the residual propagation.
npm audit reported 42 findings against the Docusaurus toolchain, 2 critical
and 26 high. This takes it to 17.

  - npm audit fix updated the webpack-dev-server, sockjs, ws and
    websocket-driver chains within the ranges their parents already allow,
    which cleared both criticals
  - serialize-javascript and uuid had no fix path through their parents, so
    they are pinned through overrides to ^7.1.0 and ^11.1.1

The remaining 17 are all image-size, reached through @docusaurus/mdx-loader.
Its advisory range is *, meaning every published version including the latest
is affected, so there is nothing to upgrade to. It parses image dimensions at
build time, and the only images in this repository are a checked-in logo and
favicon.

All of these are build-time dependencies. The deployed site is static HTML, so
the exposure is a developer running the dev server and the CI build, not
anyone reading the documentation.

Docusaurus itself stays at 3.9.2. 3.10.2 was tried first and rejected: it left
the audit count unchanged at 24, and it breaks the build, because the site
sets future.v4 and 3.10 requires the @docusaurus/faster package for it.

Verified that npm ci reproduces the tree from the lockfile and that npm run
build succeeds from that clean install, since the docs deploy and release
workflows both depend on it.
The encoding entry quoted a round-trip figure that the later chunk size
change supersedes, which read as a contradiction against the figure in that
entry. It now says which chunk size it was measured at and points forward.

Bullets within a section are contiguous elsewhere in the file; these were
separated by blank lines.
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@chrislupp chrislupp self-assigned this Aug 29, 2026
@chrislupp chrislupp added the enhancement New feature or request label Aug 29, 2026
@chrislupp chrislupp added this to the Version 0.9.0 milestone Aug 29, 2026
@chrislupp
chrislupp merged commit 23cd661 into develop Aug 29, 2026
8 checks passed
@chrislupp
chrislupp deleted the feature/efficiency branch August 29, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant