Skip to content

Fix double precision issues, improve serialized size of several repeating structures, removed automatic fields - #83

Merged
gonzalocasas merged 12 commits into
mainfrom
benchmark/double-precision
Jul 28, 2026
Merged

gonzalocasas merged 12 commits into
mainfrom
benchmark/double-precision

Conversation

@gonzalocasas

Copy link
Copy Markdown
Member

This is a breaking change of the pb wire format, so, it will need a major release.
There's several things done in this PR, all of them driven by a benchmark to compare compas_pb serialization size and speed against json and against other options. I will push the full benchmark code and results to core but here're the results so far:

Full size samples
image

Full type coverage benchmark with smaller sizes
image

The summary of changes is:

  • Fixed lossy float vs double serialization
  • Removed automatic guid and names from serialization, i.e. if the guid or name has been assigned for real, it's serialized, but if it's the auto generated one, it's not
  • Fixed an int vs float conversion weirdness
  • Changed all message types that encoded repeated PointData to use instead a flat-packged repeated double layout
  • Changed attribute serialization from per-element dicts to use a columnar approach via repeated AttributeColumn
  • Removed empty dicts from the serialized output
  • Changed Rotation to store lossless as a 4x4 matrix, I don't understand why it was done using axis angle

What type of change is this?

  • Bug fix in a backwards-compatible manner.
  • New feature in a backwards-compatible manner.
  • Breaking change: bug fix or new feature that involve incompatible API changes.
  • Other (e.g. doc update, configuration, etc)

Checklist

Put an x in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code.

  • I added a line to the CHANGELOG.md file in the Unreleased section under the most fitting heading (e.g. Added, Changed, Removed).
  • I ran all tests on my computer and it's all green (i.e. invoke test).
  • I ran lint on my computer and there are no errors (i.e. invoke lint).
  • I added new functions/classes and made them available on a second-level import, e.g. compas.datastructures.Mesh.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added necessary documentation (if appropriate)

gonzalocasas and others added 12 commits July 10, 2026 00:48
…ayout

Reworks the wire format so protobuf becomes a fully lossless binary mode that
is smaller and faster than JSON on numeric-heavy data.

Precision & fidelity
- geometry.proto: all coordinate/scalar fields float -> double (PointData etc.),
  so float64 geometry round-trips exactly (coordinate error 0).
- AnyData: explicit int64/double arms instead of routing numbers through
  google.protobuf.Value, so an integral float (0.0) no longer comes back as int.
  Makes Mesh/Graph fully lossless (canonical hash matches).

Compact numeric layout
- Mesh/Pointcloud/Polyline/Polygon/Bezier/Polyhedron: vertices/points stored as a
  flat packed `repeated double` (x,y,z triplets) instead of one PointData message
  per point (each of which also carried a per-point UUID + name).
- MeshData faces in CSR form: flat `face_vertices` index array + `face_sizes`
  lengths, instead of one FaceList message per face.
- Mesh/Graph attributes as inline `map<string, AnyData>` (dropped the DictData
  wrapper); empty/default maps are skipped so they cost zero bytes.
- AnyData: explicit dict_value/list_value arms so plain nested dicts/lists no
  longer carry a google.protobuf.Any type_url (~44 bytes each). Cuts attribute-
  heavy meshes ~40% and helps Graph, fallback, and any nested container.

Tests
- Update test_json_structure_validation: a plain list now uses the listValue arm
  rather than an Any-wrapped message; round-trip tests unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the per-vertex `map<string, AnyData> vertex_attributes` (a dict + repeated
attribute-name string per vertex) with a `repeated AttributeColumn`: each attribute name
is stored once alongside a packed value array.

- Numeric columns use packed double / sint64 / bool arrays (no per-value message framing,
  bulk-decodable); mixed / non-numeric columns fall back to repeated AnyData.
- Dense columns (every vertex carries the attribute) store no index array; sparse columns
  record the carrying vertex indices.

Attribute-heavy meshes (mesh_attrs, 10k vertices, one float per vertex): wire 651 KB ->
398 KB and round-trip 130 ms -> 56 ms, now smaller AND faster to load than compact JSON.
Sparse and mixed-type columns verified lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-generated guids and default names (the class name) are no longer written to the wire.
An object serializes its guid only if `_guid` was explicitly set, and its name only if
`_name` was set; on load, an absent guid means the receiver assigns its own lazily. This
removes ~40 bytes of UUID + a name string from every object, including the Points/Vectors
nested inside Lines, Frames, and shapes.

Collections of shapes go from larger-than-JSON to smaller: boxes (10k) 3.4 MB -> 1.6 MB
(JSON 2.7 MB); similar for spheres/circles/frames/lines. Canonical hash / __data__ are
guid-independent, so round-trip fidelity is unchanged.

Contract change: auto-generated guids no longer round-trip (they are session-local uuid4s
and were never stable across save/load). Explicit guids still round-trip. Tests updated to
assert the new contract (test_explicit_guid_is_preserved / test_auto_guid_is_not_serialized).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalizes the columnar attribute layout (previously mesh vertices only) and applies it to
graphs. `_fill_attribute_columns` now takes an ordered list of attribute dicts plus an
`exclude` set, so it serves mesh vertices (coordinates excluded, they live in a flat array)
and graph nodes (nothing excluded, coordinates are node attributes).

GraphData now stores:
- node_keys once (as AnyData, preserving int/str/... keys),
- node attributes column-wise (including x/y/z as packed double columns),
- edges as index pairs into node_keys, with edge attributes column-wise.

Grid graph (10k nodes): wire 931 KB -> 360 KB and round-trip 379 ms -> 83 ms, now ~2x
smaller and ~2x faster to load than compact JSON. Arbitrary node keys, sparse and
mixed-type node/edge attributes verified lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the columnar attribute layout for meshes: face and edge attributes now use
`repeated AttributeColumn` like vertices, instead of `map<string, AnyData>`. Face columns
are indexed by face position; edge columns are aligned to an `edge_keys` list (edges carry
string keys). Empty/absent attribute sets cost nothing.

With this, every per-element attribute collection in compas_pb is columnar: mesh
vertices/faces/edges and graph nodes/edges. Populated face/edge attributes with mixed
value types (float/str/bool) verified lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deserialization now raises on a missing or incompatible wire version instead of warning and
continuing. Because compas_pb reuses protobuf field numbers across format revisions,
mismatched data can silently misparse rather than fail cleanly, so an unverifiable version
must be refused.

Compatibility is keyed on MAJOR.MINOR (under 0.x each minor may change the schema). The
version string is unchanged here; bumping it at release time is what activates rejection of
older data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RotationData encoded axis + angle, so the 4x4 matrix was recomputed on load and lost ~1e-16
(canonical hash mismatched). It now stores the flattened matrix directly, like every other
transform (Scale/Shear/Reflection/Projection/Transformation), and reconstructs via
Rotation.from_matrix. Rotations now round-trip bit-exact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The compat key was MAJOR.MINOR for all versions, which would wrongly reject
a 1.0 -> 1.2 upgrade even though minor releases are backwards-compatible
under SemVer. Now key on MAJOR.MINOR only under 0.x, and on MAJOR from 1.0
on, so 1.0 and 1.2 stay compatible while 2.0 is refused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chenkasirer chenkasirer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing! LGTM!

@WeiTing1991

Copy link
Copy Markdown
Member

Amazing!!! 🚀

@gonzalocasas
gonzalocasas merged commit 82f6d76 into main Jul 28, 2026
18 checks passed
@gonzalocasas
gonzalocasas deleted the benchmark/double-precision branch July 28, 2026 11:55
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.

3 participants