ONNX Runtime PR #32303: Why a Successful Compile Produced Duplicate Graph Definitions
ONNX Runtime PR #32303: Why a Successful Compile Produced Duplicate Graph Definitions
ONNX Runtime PR #32303 fixes a small object-selection error with a much larger consequence.
The Compile API could report success after writing an ONNX model that contained duplicate graph definitions. Loading that output then failed with an error such as:
Duplicate definition-site for (X).The graph was not duplicated because the compiler deliberately serialized it twice. It was duplicated because the serializer cleared one GraphProto and appended into another.
The destination already contained a graph
The affected path used a custom callback to determine how initializers should be stored.
At the model level, serialization began by copying the model’s existing protobuf state into the caller’s output object:
model_proto = model_proto_;That assignment included the existing graph.
The destination graph therefore already contained its inputs, outputs, nodes, value information, and initializers.
The function then asked the in-memory Graph object to serialize its current state into that destination:
graph.ToGraphProtoWithCustomInitializerHandling(
handle_initializer_func,
state,
*model_proto.mutable_graph());For that operation to replace the existing graph fields, the graph-level serializer first needed to clear the fields it was responsible for regenerating.
The old code cleared the backing proto
Graph::ToGraphProtoInternal() received the destination as a reference named graph_proto.
The Graph object also held a pointer named graph_proto_ to its own backing protobuf.
Those objects can refer to the same graph in an ordinary synchronization path, but they were different objects in this custom output path.
The old implementation cleared the member pointer:
graph_proto_->clear_node();
graph_proto_->clear_input();
graph_proto_->clear_output();
graph_proto_->clear_value_info();It then populated the function parameter:
graph_proto.add_input();
graph_proto.add_output();
graph_proto.add_node();The clear and the append were operating on different objects.
A minimal example looks like this:
Backing graph:
input X
output Z
node Add
Destination graph after model copy:
input X
output Z
node AddThe old serializer then performed:
Clear the backing graph
Append current graph state
to the destination graphThe destination became:
inputs:
X
X
outputs:
Z
Z
nodes:
Add
AddThe backing graph, meanwhile, had its serialized fields removed even though the caller had asked the function to populate a separate output object.
Why writing the model still succeeded
A serialized protobuf message can contain repeated entries in repeated fields.
The write operation therefore did not have to fail merely because two input entries used the same name or two nodes defined the same values.
The semantic conflict surfaced later, when ONNX Runtime loaded and validated the emitted model.
At that point, two definitions of X were no longer harmless repeated protobuf entries. They violated the graph’s definition rules.
This explains the apparently contradictory result:
CompileModel:
success
Model serialization:
success
Reloading the emitted graph:
failureSerialization success proved that bytes were produced. It did not prove that those bytes represented a valid ONNX graph.
The fix clears the destination
The merged change removes the underscore from the four clear targets:
graph_proto.clear_node();
graph_proto.clear_input();
graph_proto.clear_output();
graph_proto.clear_value_info();The function now clears the same object it later repopulates.
That preserves both relevant use cases.
Ordinary graph synchronization
When the caller passes the graph’s own backing proto, the destination and backing object are the same object.
The function clears and regenerates that graph as before.
Serialization into a separate output
When the caller passes a copied or independently created destination, only that destination is cleared.
The backing graph remains untouched, and the destination receives one regenerated set of graph fields.
The fix does not move initializer handling into ToGraphProtoInternal().
That separation is intentional. The custom initializer path clears existing dense and sparse initializers separately, then re-adds each initializer according to the user callback’s decision.
The resulting ownership is clear:
ToGraphProtoInternal
owns regeneration of:
inputs
outputs
nodes
value information
Custom initializer handling
owns regeneration of:
dense initializers
sparse initializersWhy the custom initializer path exposed the mistake
Other serialization paths commonly build a fresh GraphProto and assign the completed result into the model.
The custom initializer path behaved differently.
It first copied the complete ModelProto, preserving model-level metadata and other fields. It then modified the graph already inside that copy.
That made the correctness of in-place graph replacement important.
The reported Compile API case also used an output write callback. After constructing the output ModelProto, ONNX Runtime serialized it through the supplied stream callback.
The write callback did not cause the duplicated definitions. It delivered the already malformed model produced by the custom graph serialization path.
The underlying defect was therefore below the output transport boundary:
Compile API options
↓
Build optimized ModelProto
↓
Custom initializer serialization
↓
Wrong GraphProto cleared
↓
Graph fields appended to populated destination
↓
Output callback writes duplicated graphThe tests cover the observable failure
The added Compile API tests construct a small Add model and exercise the affected output configuration.
One test uses no initializer. It verifies that:
inputs appear once
output appears once
node appears once
initializer callback is not called
the emitted model parses
the emitted model runsAnother test includes an initializer and exercises two callback decisions:
keep initializer embedded
or
place initializer in an external fileFor both cases, the test confirms that the initializer callback runs once, the graph fields appear once, the initializer appears once, and the resulting model can be loaded and executed.
A lower-level graph test also checks exact input, output, and node counts in the custom initializer serialization path.
These tests protect the user-visible contract that failed: compilation must emit a model that survives its next load and produces the expected result.
What the tests do not directly assert
The production fix clears four graph field groups:
node
input
output
value_infoThe added assertions explicitly count nodes, inputs, outputs, and initializers. They do not include an exact-count assertion for a non-empty value_info collection.
The tests also do not serialize the same model repeatedly and compare the results, or separately assert that the source model’s backing proto remains unchanged.
Those are narrower coverage gaps, not evidence that the patch is incorrect.
The changed expression applies the same destination rule to all four regenerated graph fields. The principal regression is also exercised through both direct graph serialization and the public Compile API path.
The contract restored by the patch
An output parameter identifies the object a function has been asked to populate.
If that object is already populated, the function must replace the fields it owns before adding their current values.
Clearing a related source or backing object does not make the destination empty.
The repaired contract is:
Receive destination GraphProto
↓
clear destination-owned graph fields
↓
serialize current in-memory graph
↓
rebuild initializers through their selected policy
↓
emit one valid graphA serializer must clear the destination it will populate, not a different object that happens to represent the same graph.
That is the four-line boundary repaired by ONNX Runtime PR #32303.
Sources
https://github.com/microsoft/onnxruntime/pull/32303
https://github.com/microsoft/onnxruntime/commit/cf5737297dd8c77a3b5499607b9fe2480e171748
https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/core/graph/graph.cc
https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/core/graph/model.cc
https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/core/framework/ep_context_utils.cc
https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/test/framework/inference_session_test.cc
https://github.com/microsoft/onnxruntime/blob/main/onnxruntime/test/ir/graph_test.cc