Skip to content

Save the etrecord the lowered paths already generate - #22303

Open
shoumikhin wants to merge 7 commits into
pytorch:mainfrom
shoumikhin:llama-etrecord-lowered
Open

Save the etrecord the lowered paths already generate#22303
shoumikhin wants to merge 7 commits into
pytorch:mainfrom
shoumikhin:llama-etrecord-lowered

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Asking for an etrecord on a lowered llama export produced no file and no warning. The record was
built, carried through to the ExecuTorch program, and then dropped.

--generate_etrecord   ->  model written, no etrecord.bin, no message

to_edge_transform_and_lower attaches the record when asked, but nothing saved it, so the cost of
building it was paid and the artifact thrown away.

What changes

The record is saved once, after save_to_pte, from the two export entry points. Three paths reach
it now: the XNNPACK lowering, the Core ML lowering, and the multimethod export, which dropped its
record the same way.

Four behaviours worth calling out, all added after the first revision:

  • The write happens after the model, and a failure is logged rather than raised. Before that, an
    unwritable target took the whole export down and left no .pte at all. A record roughly twice the
    size of the model makes a full disk the likely trigger, and losing a model to a debug flag is the
    wrong trade.
  • The combined lowering already had its own record write, before the model and unguarded. It now
    hands the record to the same post-model step instead, so there is one write site and one rule.
  • Every path now takes the record from the program rather than building one. The combined lowering
    was the last that did it by hand, with the standalone generator and a deep copy of the edge
    manager. to_edge already accepted the flag and both to_backend and to_executorch already
    carried the record forward; the two shared export helpers in between did not pass the flag on, so
    this adds it to both, defaulted off, leaving their other callers unchanged.
  • The record lands beside the model, not under export.output_dir. An output name ending in
    .pte is used as the path verbatim, so it bypasses output_dir, and keying the record off
    output_dir would put the pair in two different directories for the documented combination of
    export.output_dir plus a .pte-suffixed export.output_name.
  • The record is staged under a temporary name and moved into place. The record format truncates its
    target when it opens it, so writing in place turned a failure part way through into a short file
    under the real name, destroying any previous record while reporting only a warning.

One difference worth stating

Asking the program for the record also gets the aten exported program, which the hand-built one on
the combined path did not carry. So that path's record is now roughly twice the size for the same
model: measured 8.5 MB before and 17 MB after on a stack of eight linear layers. That is more to
debug with, and it is what the other paths already produced. Peak Python memory is unchanged at
30.8 MB either way, so removing the deep copy is about having one mechanism rather than about
memory.

Test plan

Six tests, driving the real export with the model preparation stubbed:

saves the record beside the model output_dir points elsewhere; both files still land together
writes none when not asked ['tiny.pte'], and no warning logged
keeps the model when the record cannot be written .pte present, warning logged, no stray file
keeps the previous record when a rewrite fails earlier record is byte-for-byte unchanged
combined lowering saves the record ['etrecord.bin', 'tiny.pte'], and it loads back
multimethod export saves the record ['etrecord.bin', 'tiny.pte']

Each test was checked by reverting the behaviour it is named for and confirming it fails:

reverted result
record path back to output_dir 1 failure
guard narrowed from Exception to OSError 1 error
staged write replaced by an in-place write 1 failure
flag no longer set before the combined path's edge export 1 failure
record call removed from the multimethod path 1 failure

The combined lowering test is the one that earned its place: the first version of that change set
the flag at the lowering rather than before the edge export, and that path then produced no record
at all while every other test still passed.

The tests point the builder at a temporary directory rather than changing the process working
directory, because the suite runs under pytest -n auto where the working directory is shared.

Not covered

  • No real llama checkpoint, so the size comparison above is from a small model.
  • The Core ML path is asserted by reading the code and by mutation, not by running a Core ML
    lowering.
  • The Arm, MLX and OpenVINO lowerings never set the flag on the builder, so asking for a record on
    those paths still produces no file and no warning. That predates this change and is left alone
    here rather than wired up on paths this change does not touch.
  • Linux x86-64 only, one Python version. No macOS, no Windows, no ARM host.

Copilot AI lite review requested due to automatic review settings August 29, 2026 11:38
@pytorch-bot

pytorch-bot Bot commented Aug 29, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22303

Note: Links to docs will display an error until the docs builds have been completed.

❌ 3 New Failures, 15 Pending

As of commit 958270e with merge base b213481 (image):

NEW FAILURES - The following jobs have failed:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 29, 2026

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin shoumikhin added the release notes: examples Changes to any of our example LLMs integrations, such as Llama3 and Llava label Aug 29, 2026
Copilot AI review requested due to automatic review settings August 30, 2026 17:09

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 31, 2026 03:58

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Thanks, this was a useful pass. Fixed and pushed:

The docstring promised the save never raises but only OSError was caught. Injecting a
RuntimeError from the save reproduced it escaping after the .pte was already written, which is
the exact outcome the change exists to prevent. Widened to Exception, matching save_pte_program
one line above.

The combined lowering was the third path and still wrote its record before the model, unguarded. It
is older than this change but breaks the same rule, so it warns and continues now too.

Three test corrections. The positive test only checked the filename, so a zero byte record passed; it
now loads the record with parse_etrecord and fails when the save is replaced by an empty file. The
failure test now asserts the warning it is named for. And its docstring claimed an unwritable
directory loses only the record, which I measured as false: in a read only directory both artifacts
are lost and the export still reports success, because the model save swallows its own error. That
half is gone.

Also removed the dynamic shape line from the test setup. Validation rejects dynamic shapes only for
Core ML and QNN, and these tests enable XNNPACK, so nothing read it.

Two I am not changing here, with reasons:

The bare etrecord.bin path. Real gap, but the combined path already writes the bare name while
the model honours the output directory. Fixing one path and not the other makes the split harder to
see. Noted in the description as known.

Inferring the flag from the attached record instead of reading it. You are right that the
OpenVINO, TOSA, Ethos-U, VGF and MLX helpers never set it, so the flag is still ignored there. That
is a wider fix than this change and wants its own commit rather than a partial version here.

The description was rewritten against the current code, and the Closes link is dropped: the Core ML
arm cannot run until the stale backend field read is fixed, so this does not close that issue.

@shoumikhin
shoumikhin force-pushed the llama-etrecord-lowered branch from 6e6b9db to a438b6e Compare August 31, 2026 15:05
Copilot AI review requested due to automatic review settings August 31, 2026 15:32

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Second pass, all four addressed.

The record now goes where the model goes. I argued last time this was better left alone because
the combined path already wrote a bare name. That was the wrong call: the builder carries the output
directory and both write sites already had it in hand, so fixing both was smaller than explaining why
neither was fixed. With --output-dir set, the record and the model now land together.

The helper exported twice. _export_llama exports the builder it is given, so returning an
already exported one traced the tiny model twice in each test. Removed.

On the tests barely failing at the merge base: two of the three now fail there, not one. The
warning assertion I added to the failure test made it load-bearing. The third is the negative case,
which passes on the merge base because nothing wrote a record there either. That is the correct result
for it rather than a gap, so I have left it.

The Core ML claim is gone from the description. You were right that the arm is unreachable: the
branch selecting it reads a backend field that no longer exists, so a Core ML export raises before
this code runs. The Closes link is removed and the description now says the Core ML arm is blocked
until that lands.

Also corrected the commit messages, which still described the first revision: one helper as the
writer, two tests, all three paths leaving the same artifact, and a base failure the current tests do
not produce. Since this repository squashes with the description, that text is what would have landed.

Copilot AI review requested due to automatic review settings September 1, 2026 16:52
@shoumikhin
shoumikhin force-pushed the llama-etrecord-lowered branch from 4e88698 to a02d092 Compare September 1, 2026 16:52

Copilot AI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment on lines +1230 to +1235
path = os.path.join(builder.output_dir, "etrecord.bin")
try:
etrecord.save(path)
except Exception as error:
logging.warning("Could not write %s: %s", path, error)
return

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, and fixed. An output name ending in .pte is used verbatim, so the model landed in the working directory while the record went to the output directory. The record now follows the saved model path, with the output directory as the fallback before anything is saved. Verified both ways: with output_name "m.pte" both files land in the working directory, and with "m" both land under the output directory.

@shoumikhin
shoumikhin force-pushed the llama-etrecord-lowered branch from a02d092 to bcda29f Compare September 1, 2026 18:21
Copilot AI review requested due to automatic review settings September 1, 2026 18:21

Copilot AI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

examples/models/llama/export_llama_lib.py:1234

  • _save_etrecord_if_generated always writes etrecord.bin under builder.output_dir, but the .pte output path can be overridden via --output_name (including a full/relative path ending in .pte) which bypasses output_dir in _get_output_filename/save_pte_program. In that case the model and record end up in different directories. Consider deriving the etrecord directory from the actual saved .pte filename (available via builder.get_saved_pte_filename()) so the artifacts stay colocated.
    path = os.path.join(builder.output_dir, "etrecord.bin")
    try:
        etrecord.save(path)
    except Exception as error:
        logging.warning("Could not write %s: %s", path, error)

Comment on lines +1571 to +1574
if edge_manager_copy:
generate_etrecord_func(
et_record="etrecord.bin",
edge_dialect_program=edge_manager_copy,
executorch_program=builder.export_program,
)
logging.info("Generated etrecord.bin")
et_record_path = os.path.join(builder.output_dir, "etrecord.bin")
try:
generate_etrecord_func(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, the description was stale. It described the behaviour before the path change, and it has been updated to say the record goes beside the model rather than into the working directory.

Copilot AI review requested due to automatic review settings September 1, 2026 21:46

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Gasoonjia Gasoonjia 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.

instead of using generte_etrecord_func, can we use the etrecord.save() function? #12925

Copilot AI review requested due to automatic review settings September 1, 2026 23:29

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@shoumikhin

Copy link
Copy Markdown
Contributor Author

Done, on every path now.

The backend-specific lowerings already worked that way, so the only holdout was the combined one, which built the record by hand with the standalone call and a deep copy of the edge manager. I had assumed converting it meant a wider change, but the plumbing was already there: to_edge takes the flag, to_backend and to_executorch both carry the record forward, and get_etrecord returns it at the end. The only gap was the two shared export helpers between the builder and to_edge, which did not pass the flag on. Adding it to both, defaulted off, leaves their other callers alone.

That collapses the save helper to one branch and removes the deep copy and the attribute the lowering used to hand the record over.

Two things worth knowing. Nothing covered the combined path before, and it needed covering: my first attempt set the flag at the lowering instead of before the edge export, and that path then produced no record at all while every other test still passed. There is a test for it now, and removing that one line fails it.

And the record from that path is about twice the size, 8.5 MB to 17 MB on a small stack, because asking the program for it also gets the aten exported program that the hand-built one left out. That is what the other paths already produced. I also checked the memory: peak is the same either way, so the deep copy was not costing what its comment implied and this is about having one mechanism rather than about memory.

Thanks for the pointer, this is better than what I had.

Copilot AI review requested due to automatic review settings September 2, 2026 00:23
@shoumikhin
shoumikhin force-pushed the llama-etrecord-lowered branch from c127914 to dfbe9e9 Compare September 2, 2026 00:23

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

`--generate_etrecord` produced no file and no warning on the Core ML and XNNPACK llama paths.

The flag was not being ignored, which is the part worth knowing: both helpers set
`generate_etrecord` on the builder, `to_edge_transform_and_lower` builds the record from it, and the
record travels all the way to the ExecuTorch program. Nothing ever saved it. So the cost was already
being paid, including a deepcopy of the edge program, and the artifact was dropped at the end.

A shared helper now writes it, from the same `export_program` and to the same `etrecord.bin` name the
combined path uses. A missing record is the normal case and stays silent.

Also removed the TODO asking for exactly this.

One difference worth stating: the record from these paths is larger than the combined path's, because
`to_edge_transform_and_lower` also records the aten exported program, which the combined path does
not. Roughly twice the size on the same model. That is content, not waste, but it is a size a user
will notice.

Test plan:

Tests driving the real export rather than the save helper, so they fail on the missing file rather
than on a missing symbol:

    base   the record is absent
    head   the record is written beside the model

Also ran the full llama export on the default config with only this file swapped:

    base   ['m.pte']
    head   ['etrecord.bin', 'm.pte']

and confirmed the written record loads: `parse_etrecord` returns an ETRecord with its edge dialect
program set.
Two problems with the first version, both found in review.

The record was written inside the lowering, before the model was saved, and the write was not
guarded. So a failed record write took the whole export with it. Measured with an unwritable
target: the export raised and left no .pte, where the same run on the previous revision produced
one. A record about twice the size of the model makes a full disk the likely trigger, and losing a
model to a debug flag is the wrong trade.

The record is now written once, after `save_to_pte`, and a write error is logged and swallowed. That
also removes the duplicate call from the two lowering helpers.

The multimethod path dropped the record too. It builds one exactly as the other paths did and saved
only the .pte, so it gets the same call.

Test plan:

Three tests driving the real export with a stubbed builder:

    saves the record          base ['tiny.pte'], head ['etrecord.bin', 'tiny.pte']
    writes none when unasked  ['tiny.pte']
    keeps the model when the record cannot be written   .pte present, warning logged

The first fails on the previous revision, so it pins the fix rather than the helper.

Also corrected the docstring, which described the state before the change in the present tense and
claimed the paths leave the same artifact. They do not: this one carries the aten exported program
as well, so it is roughly twice the size.
Review found the guard did not hold where it was written down.

The docstring said the save is never allowed to raise, but only OSError was caught. Injecting a
RuntimeError from the save showed it escaping the export after the .pte was already on disk, which
is the exact outcome the change exists to prevent. Widened to Exception, which is what
save_pte_program on the line above already does.

The combined lowering was the third path and still wrote its record before the model, unguarded, so
a failed write there lost the export. That write is older than this change, but it breaks the same
rule, so it now warns and continues too.

Three test corrections:

The positive test only checked the file name, so a zero byte record passed. It now loads the record
back and asserts the edge program, which fails when the save is replaced by an empty file.

The failure test asserted only that the .pte survived, which is also true on the previous revision
where nothing wrote a record. It now asserts the warning it is named for.

Its docstring claimed an unwritable directory loses the record and not the .pte. Measured: in a
read only directory both are lost and the export still reports success, because the model save
swallows its own error. Dropped that half.

Also removed a dynamic shape line and its comment from the test setup. Validation rejects dynamic
shapes only for Core ML and QNN, and these tests enable XNNPACK, so nothing read the flag. Hoisted
torch and the builder import to module scope to match the sibling test files.
Review found the record ignored the output directory. The model honoured `--output-dir` and the
record did not, so with that flag set the larger of the two artifacts landed in the shell's working
directory, and a script collecting the requested directory missed it. Both write sites now join the
builder's output directory, so the two cannot disagree.

I had argued this was better left alone because the combined path already wrote a bare name. That was
the wrong call: the builder carries the directory and both sites already had it in hand, so fixing
both is smaller than explaining why neither is fixed.

Also removed a redundant export from the test helper. `_export_llama` exports the builder it is
given, so returning an already exported one traced the model twice per test.
…there

Three problems with the previous revision, all found in review.

The record was written under `export.output_dir`, but an output name ending in `.pte` is used as
the path verbatim and so bypasses that directory. With both set, which is the documented form in
this repo, the model and its record landed in different directories. Measured on the combined
lowering: before this stack both files landed together in the working directory, and after it the
model was in the working directory and the record was in the output directory. The record path is
now derived from the path the model was actually written to.

The record was written in place. The format truncates its target when it opens it and closes in a
finally, so a failure part way through left a short file under the real name and destroyed any
record already there, reporting only a warning. Worse, the leftover still loaded: the parser
accepted a 35479 byte remnant of a 36011 byte record. The record is now staged under a temporary
name in the same directory and moved into place, so a failed write leaves the previous record
untouched.

The combined lowering still wrote its own record before the model, so a full disk could take the
model and leave only the record. It now hands the edge program to the same post-model step, which
leaves one write site and one rule.

Also drop the function-local `import os` that the module-level one added here made dead.

Test plan:

The three earlier tests pinned none of the three fixes they were named for, because they patched
out the only caller of `set_output_dir`, so the builder's output directory was always the default
and the join could not be observed. They now point the builder at a temporary directory instead of
changing the process working directory, which both makes the directory observable and removes a
process-wide `chdir` from a suite that runs under `pytest -n auto`.

Five tests now, each checked by reverting the behaviour it is named for:

  record path back to output_dir           -> 1 failure
  guard narrowed from Exception to OSError  -> 1 error
  staged write replaced by in-place write   -> 1 failure
  record call removed from multimethod      -> 1 failure

Full file: 16 tests, 10 pass and 6 skip for a backend that is not installed here. Linux x86-64,
Python 3.12.
The combined lowering was the one path still building the record by hand, with the
standalone generator and a deep copy of the whole edge manager. Every other path asks the
program for the record the export attached and saves that.

The plumbing for the same thing was already there, just not connected: `to_edge` takes the
flag, `to_backend` and `to_executorch` both carry the record forward, and `get_etrecord`
returns it at the end. The two shared export helpers between the builder and `to_edge` did
not forward the flag, so this adds it to both, defaulted off, which leaves their other
callers unchanged.

That makes the save helper one branch instead of two and removes the deep copy along with
the attribute the lowering used to hand the record over.

One thing worth knowing about the resulting file. Asking the program for the record gets the
aten exported program as well, which the hand-built one did not carry, so the record from
this path is now about twice the size for the same model. That is more to debug with, and it
matches what the other paths already produced.

Test plan:

A new test drives the combined lowering, which nothing covered before, and checks the record
lands beside the model and loads back. It is not decoration: the first version of this change
set the flag at the lowering rather than before the edge export, and that path silently
produced no record at all. Removing that one line now fails the test.

Seventeen tests pass. Measured on a stack of eight linear layers, peak Python memory is the
same either way, 30.8 MiB, so the deep copy was not costing what its comment implied and this
is about having one mechanism rather than about memory. The record from that model goes from
8.5 MB to 17 MB, which is the aten program being included.
Copilot AI review requested due to automatic review settings September 2, 2026 01:04
@shoumikhin
shoumikhin force-pushed the llama-etrecord-lowered branch from dfbe9e9 to fba1fb7 Compare September 2, 2026 01:04

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings September 2, 2026 03:58

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: examples Changes to any of our example LLMs integrations, such as Llama3 and Llava

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants