Skip to content

feat(hipdnn): re-save a loaded execution plan from the bytes it was loaded from - #14517

Merged
GarbyGilbertAMD merged 5 commits into
developfrom
users/ggilbert/ALMIOPEN-2940/resave-loaded-plan
Oct 30, 2026
Merged

GarbyGilbertAMD merged 5 commits into
developfrom
users/ggilbert/ALMIOPEN-2940/resave-loaded-plan

Conversation

@GarbyGilbertAMD

@GarbyGilbertAMD GarbyGilbertAMD commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A plan loaded from saved bytes could not be saved again: the backend asked the plugin to serialize the restored execution context, and hip-kernel-provider refuses to capture a restored plan, so to_binary() of a graph loaded with its plan and to_compiled_plan_binary() of a loaded plan both failed. The backend now keeps the plan bytes a plan was loaded from and returns them on a later save, without calling the plugin. When the input is a graph-and-plan container, only its plan part is kept. The design decision is recorded in RFC 0009 ("Decision: Re-Save From the Loaded Bytes").

JIRA ID : ALMIOPEN-2940

Follow-up to #13315, which deferred re-saving a loaded plan to this ticket.

Risk Assessment

2 — ExecutionPlanDescriptor::deserializeBackendPlan seeds the existing serialized-plan cache with one added line, and passes the plugin payload to the plugin without a temporary copy; the save path for a built plan is unchanged. No schema, public API or plugin ABI change. Behaviour change: to_binary() of a graph loaded with its plan and to_compiled_plan_binary() of a loaded plan now succeed for every plugin that exports the three serialization hooks, and hipDNN no longer calls the plugin's save hook for a loaded plan. A re-save returns the plan bytes the plan was loaded from, so it keeps the original provider and format versions; a newer kernel in an upgraded provider never enters it (rebuild the plan from its graph to pick one up). Memory cost: every loaded plan keeps one copy of its plan bytes for its lifetime, whether or not it is saved again, while a built plan keeps a copy only after its first save. For hip-kernel-provider plans the bytes include the kernel code object. Load no longer copies the plugin payload before it hands it to the plugin, which lowers peak memory at load.

ASIC Coverage

Architecture-independent host code in the backend descriptor; the provider change is a message and comments. PR CI is sufficient. Verified locally on gfx1152 (Windows), where all four GPU save/load cases ran; PR CI provides the Linux build and the other architectures.

Testing Summary

  • Backend unit: a loaded plan re-saves byte-identically without calling the plugin; a version 1 envelope stays at version 1 (a rebuilt envelope would claim version 2 with no tensor alignments and fail to load); a failed restore leaves the plan unfinalized and unserializable; a built plan still fills the cache on its first save, including concurrent saves and a retry after a failed fill.
  • Provider unit: the save hook still refuses a direct call on a restored plan, with the reworded message.
  • GPU integration: pointwise and convolution plans, each as a graph with its plan and as a plan alone, are saved, loaded in a fresh handle, saved again, loaded a second time in a fresh handle and validated. For a graph with its plan, the plan bytes that the loaded and the reloaded graph save equal the plan bytes of the original plan.
  • Mutation check: with the cache fill removed, the new backend tests and all four GPU save/load cases fail.

Testing Checklist

  • hipDNN backend unit tests - hipdnn_backend_tests (-DHIPDNN_ENABLE_KERNEL_INGESTOR=ON) - Status: Passed
  • hip-kernel-provider unit tests - hip_kernel_provider_tests - Status: Passed
  • Save/load GPU integration - hip_kernel_provider_integration_tests --gtest_filter=IntegrationGpuKernelIngestorSaveLoad* - ASIC: gfx1152 - Status: Passed
  • Test-name validation - ctest -R test_name_validation (hipDNN and hip-kernel-provider) - Status: Passed
  • PR CI - GitHub PR checks - Status: Pending

Technical Changes

  • ExecutionPlanDescriptor::deserializeBackendPlan assigns the plan bytes it was loaded from to _serializedPlanCache after the plugin restores the context and before finalize(); serializeBackendPlan returns the cache when it is populated.
  • hip-kernel-provider keeps refusing a direct save-hook call on a restored plan; the message and comments state that hipDNN saves a loaded plan from its plan bytes and never makes that call.
  • Backend descriptor tests and the GPU save/load integration tests cover the re-save.
  • Graph::serialize_compiled_plan documents that a plan loaded from bytes can be saved.
  • RFC 0009, PluginDevelopment.md and the hip-kernel-provider README describe the behaviour and the memory cost.

…oaded from

A plan loaded from saved bytes could not be saved again. The backend asked
the plugin to serialize the restored execution context, and hip-kernel-provider
refuses to capture a plan it restored from a payload, so to_binary() of a graph
loaded with its plan and to_compiled_plan_binary() of a loaded plan failed.

ExecutionPlanDescriptor::deserializeBackendPlan seeds the serialized plan
cache with the exact bytes it loaded, after the plugin restores the context and
before the descriptor is finalized. A later save returns those bytes and never
calls the plugin. A re-save therefore describes the kernel the loaded plan runs,
with its original provider version and format versions; a newer kernel in an
upgraded provider never enters it. To use a newer kernel, build the plan again
from its graph and save it.

Keeping the loaded bytes in the backend was chosen over re-encoding the plan
inside the provider. It works for every plugin and every payload shape, adds no
contract for plugin authors, and makes a re-save byte-identical. It also keeps a
version 1 envelope at version 1, where a rebuilt envelope would claim version 2
with no tensor alignments and fail to load. The cost is one copy of each loaded
plan's bytes for the plan's lifetime, the same copy a saved built plan keeps.

The provider still refuses a direct call of its save hook on a restored
context, with a message that names the backend behaviour. The backend tests
cover the byte-identical re-save, the version 1 envelope, a failed restore, and
the first-save cache of a built plan, including concurrent saves and a retry
after a failed fill. The GPU save/load integration tests save the loaded plan
again, load the second blob in a fresh handle, and validate it for pointwise
and convolution, as a graph with its plan and as a plan alone. RFC 0009, the
plugin development guide and the provider README describe the behaviour.

ALMIOPEN-2940
@therock-pr-bot

therock-pr-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

✅ All Checks Passed — Ready for Review

Check Status Details
📝 PR Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ✅ Pass —
🔎 pre-commit ✅ Pass —
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —
🤖 therock-pr-bot ✅ Pass —

🎉 All checks passed! This PR is ready for review.

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

🙋 Wish to Override Policy?

@therock-pr-bot

therock-pr-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🎉 All checks passed! This PR is ready for review.

@codecov

codecov Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Additional details and impacted files
@@             Coverage Diff             @@
##           develop   #14517      +/-   ##
===========================================
- Coverage    77.35%   77.35%   -0.00%     
===========================================
  Files         2894     2894              
  Lines       487094   487094              
  Branches     73692    73692              
===========================================
- Hits        376774   376771       -3     
- Misses       85462    85465       +3     
  Partials     24858    24858              
Flag Coverage Δ *Carryforward flag
TensileLite-CPP 50.96% <ø> (ø) Carriedforward from b93a11c
TensileLite-Unit 84.03% <ø> (ø) Carriedforward from b93a11c
hipBLAS 90.62% <ø> (ø) Carriedforward from b93a11c
hipBLASLt 68.59% <ø> (ø) Carriedforward from b93a11c
hipCUB 82.68% <ø> (ø) Carriedforward from b93a11c
hipDNN 86.91% <100.00%> (-<0.01%) ⬇️
hipFFT 43.18% <ø> (ø) Carriedforward from b93a11c
hipRAND 76.12% <ø> (ø) Carriedforward from b93a11c
hipSOLVER 69.67% <ø> (ø) Carriedforward from b93a11c
hipSPARSE 87.03% <ø> (ø) Carriedforward from b93a11c
rocBLAS 69.11% <ø> (ø) Carriedforward from b93a11c
rocFFT 47.36% <ø> (ø) Carriedforward from b93a11c
rocRAND 57.32% <ø> (ø) Carriedforward from b93a11c
rocSOLVER 76.74% <ø> (ø) Carriedforward from b93a11c
rocSPARSE 83.54% <ø> (ø) Carriedforward from b93a11c
rocThrust 93.15% <ø> (ø) Carriedforward from b93a11c

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
...ackend/src/descriptors/ExecutionPlanDescriptor.cpp 90.11% <100.00%> (ø)
.../hipdnn/frontend/include/hipdnn_frontend/Graph.hpp 81.33% <100.00%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@GarbyGilbertAMD
GarbyGilbertAMD marked this pull request as ready for review October 28, 2026 16:13
@GarbyGilbertAMD
GarbyGilbertAMD requested a review from a team as a code owner October 28, 2026 16:13

@BrianHarrisonAMD BrianHarrisonAMD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM.

Some minor feedback to consider pre-merge:

Comments

  1. The graph-and-plan re-save check is too weak. In IntegrationGpuKernelIngestor.cpp, the GRAPH_AND_PLAN case only checks that the
    second save contains a plan. It does not check that the plan inside is unchanged. That is the path where
    hipdnnBackendCreateAndDeserializeExecutionPlan_ext cuts the plan out of the combined container. Suggest checking that
    to_compiled_plan_binary() returns the same bytes for loaded and reloaded.
  2. The docs' "original bytes" wording is too broad. If you pass a combined graph-and-plan container to from_compiled_plan_binary,
    only the plan part is cached. to_compiled_plan_binary() then returns just the plan, not the container you passed in. That
    behaviour is right, but the RFC and README say "the bytes it was loaded from", which reads like the whole input. Saying "the plan
    bytes it was loaded from" fixes it.
  3. The memory note in the PR description is wrong. It says this is "the same copy a saved built plan already keeps". A built plan
    only keeps the copy once it has been saved. Every loaded plan now keeps one whether or not it is saved again, and
    hip-kernel-provider plans contain the kernel code.
  4. Optional: deserializeBackendPlan makes a temporary copy of the plugin payload that it doesn't need; it could pass
    serializedPluginPayload->data() directly. That copy predates this PR, but loading now also allocates the cache, so peak memory at
    load is higher.
  5. Optional: the public header comment on Graph::serialize_compiled_plan still says "Requires build_plans() or build()". A plan
    loaded from bytes can now be saved too, and the header is where users will look.

…bytes

A loaded plan keeps the plan bytes it was loaded from and returns them on a
later save. When the input to from_compiled_plan_binary() is a graph-and-plan
container, only its plan part is kept, so to_compiled_plan_binary() returns the
plan and not the container. The descriptor, the frontend header, RFC 0009 and
the provider README now say plan bytes instead of the bytes it was loaded from.

The documentation also states the memory cost exactly. Every loaded plan keeps
one copy of its plan bytes for its lifetime, whether or not it is saved again,
while a built plan keeps a copy only after its first save. For the kernel
ingestor engines the plan bytes include the kernel code object.
Graph::serialize_compiled_plan() states that a plan loaded from bytes can be
saved as well as a built plan.

The save and load integration tests of a graph with its plan now compare the
plan bytes that the loaded graph and the reloaded graph save with the plan bytes
of the original plan, which covers the path that cuts the plan out of the
container. deserializeBackendPlan() hands the plugin payload to the plugin
directly instead of copying it first, which lowers peak memory at load.

ALMIOPEN-2940

@CMiservaAMD CMiservaAMD left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM. The item described in this comment isn't really in scope of this PR but it looks like something that would be good to clear-up as it's related to loading graph-only (trying to load graph-only from a file that doesn't have a graph isn't properly captured as an error): https://github.com/ROCm/rocm-libraries/pull/14517/changes#r4406618366

…h rejection

A re-save of a loaded plan succeeds and returns the plan bytes it was loaded
from, so it teaches a caller nothing about the kernel inside. A caller who
upgrades the provider for a kernel fix and then loads and saves their plans
gets the old kernel back. The frontend and backend headers now say so at each
save and load entry point.

Graph::serialize_compiled_plan() states that a loaded plan is not rebuilt,
that its bytes still hold the original kernel and the provider and format
versions, that loading a graph-and-plan container keeps only its plan part,
and that a newer kernel needs a new plan built from the graph. Its error
message for a graph without a finalized plan also names
from_compiled_plan_binary(). serialize() and to_binary() describe when they
embed the plan, to_compiled_plan_binary() follows serialize_compiled_plan(),
and the graph-only loaders point out that loading the graph alone is how to
replace a plan saved by an older provider. The load entry points, and
hipdnnBackendCreateAndDeserializeExecutionPlan_ext() in the C header, state
that a restored plan keeps a copy of its plan bytes, including any GPU code
object, for its lifetime. hipdnnBackendGetSerializedExecutionPlan_ext()
states that a deserialized plan returns its loaded bytes unchanged without
calling the plugin. RFC 0009 limits the byte-identical guarantee to the plan;
a re-save of the graph is not guaranteed to be byte-identical.

A bare plan and a bare graph both carry no file identifier, so
hipdnnBackendCreateAndDeserializeGraph_ext() parses a bare plan as a graph and
rejects it only because the Graph verifier fails on the plan's fields. A new
test saves a plan with the production writer and expects the graph loader to
reject it with HIPDNN_STATUS_BAD_PARAM and no descriptor.

ALMIOPEN-2940
@GarbyGilbertAMD

Copy link
Copy Markdown
Contributor Author

LGTM. The item described in this comment isn't really in scope of this PR but it looks like something that would be good to clear-up as it's related to loading graph-only (trying to load graph-only from a file that doesn't have a graph isn't properly captured as an error): https://github.com/ROCm/rocm-libraries/pull/14517/changes#r4406618366

Logged ALMIOPEN-3105 to capture this

Brings the branch up to date with develop. The save rules in RFC 0009 keep the branch's list,
which moves the restored-plan refusal out of the numbered rules, and add develop's rule that a
kernel with more than one launch cannot be saved, in the order captureIngestorPlan() checks.

ALMIOPEN-2940
Brings in the documentation of plan device binding. The deserialize_compiled_plan() comment keeps
both paragraphs: the restored plan keeps its plan bytes and returns them on a later save, and it is
bound to the device of the load handle, so moving it to another device means deserializing the
same bytes there.

ALMIOPEN-2940
@GarbyGilbertAMD
GarbyGilbertAMD enabled auto-merge (squash) October 30, 2026 16:15
@GarbyGilbertAMD
GarbyGilbertAMD merged commit 561de63 into develop Oct 30, 2026
254 of 258 checks passed
@GarbyGilbertAMD
GarbyGilbertAMD deleted the users/ggilbert/ALMIOPEN-2940/resave-loaded-plan branch October 30, 2026 21:08
@assistant-librarian

Copy link
Copy Markdown
Contributor

TheRock Submodule Bump Activity

Newest first

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants