Skip to content

hpt: never re-initialise pretrained stems; hash-check them; mask text padding - #645

Draft
RyanPCo wants to merge 2 commits into
ryanco/proprio-historyfrom
ryanco/text-encoder-fixes
Draft

RyanPCo wants to merge 2 commits into
ryanco/proprio-historyfrom
ryanco/text-encoder-fixes

Conversation

@RyanPCo

@RyanPCo RyanPCo commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

HPTModel.finalize_modules ran self.apply(self._init_weights) over the whole
model, which xavier-randomised the pretrained HF encoders inside the text stems
(the frozen Qwen3-Embedding weights were overwritten in place and their biases
zeroed). Modules that own pretrained weights now declare them through the
PretrainedWeights mixin, and the init pass walks the tree without ever
entering such a subtree, so the ResNet backbone, T5Encoder and any future
pretrained stem are covered by the same mechanism.

HPT.__init__ also called finalize_modules twice; the first call is gone.

Once the model is built, moved and upcast, verify_pretrained_weights hashes
every declared pretrained submodule and compares it with the checkpoint read
straight off the local HF snapshot, failing the run on a mismatch.

Finally, QwenPerTokenEncoder.compute_latent now passes the tokenizer
attention mask into its cross-attention. Padded positions are zeroed before the
biased proj, so they were the learned constant proj.bias, not zero, and got
real attention weight: text-conditioned results from before this commit are not
comparable with results after it.


Folded in (to keep the stack under Graphite's 50-branch cap):

🤖 Generated with Claude Code

https://claude.ai/code/session_01HjCQBqQCr2igFEMR7yUovw

RyanPCo commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/645 September 16, 2026 17:22
@RyanPCo
RyanPCo changed the base branch from graphite-base/645 to ryanco/infra-port September 16, 2026 17:22
@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/645 September 16, 2026 17:23
@RyanPCo
RyanPCo changed the base branch from graphite-base/645 to ryanco/infra-port September 16, 2026 17:23
@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/645 September 16, 2026 17:24
@RyanPCo
RyanPCo changed the base branch from graphite-base/645 to ryanco/infra-port September 16, 2026 17:25
@RyanPCo
RyanPCo changed the base branch from ryanco/infra-port to graphite-base/645 September 16, 2026 17:30
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from 5b608a7 to 35a858c Compare September 16, 2026 17:30
@RyanPCo
RyanPCo changed the base branch from graphite-base/645 to ryanco/proprio-history September 16, 2026 17:30
@RyanPCo
RyanPCo force-pushed the ryanco/proprio-history branch from e9445a0 to c0a093d Compare September 17, 2026 14:52
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from 35a858c to 52950e3 Compare September 17, 2026 14:52
@RyanPCo
RyanPCo force-pushed the ryanco/proprio-history branch from 4c4c8a5 to a88062e Compare October 12, 2026 21:50
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from 7768bf6 to 82862c3 Compare October 12, 2026 21:50
@RyanPCo
RyanPCo force-pushed the ryanco/proprio-history branch from a88062e to e38a6d9 Compare October 12, 2026 23:19
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from 82862c3 to d655d4d Compare October 12, 2026 23:19
@RyanPCo RyanPCo mentioned this pull request Oct 19, 2026
@RyanPCo
RyanPCo force-pushed the ryanco/proprio-history branch from e38a6d9 to 2158d8c Compare October 19, 2026 19:42
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from d655d4d to da62987 Compare October 19, 2026 19:43
@RyanPCo
RyanPCo force-pushed the ryanco/proprio-history branch from 2158d8c to dbdd171 Compare October 19, 2026 20:22
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from da62987 to 5d10546 Compare October 19, 2026 20:22
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from 5d10546 to d429a6a Compare October 30, 2026 18:27

@RyanPCo RyanPCo left a comment

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.

Automated adversarial review (read-only; findings were checked by a second skeptical pass before posting): 2 low, 5 nit.

Comment thread tests/unit/test_text_feature_cache.py Outdated
# --------------------------------------------------------------------------


def _qwen_snapshot() -> str | None:

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.

low (test-quality): _qwen_snapshot, QWEN_SNAPSHOT/requires_qwen and _cross_attn_specs are duplicated verbatim in test_text_encoder_fixes.py. Move them, plus a Qwen snapshot fixture, into tests/unit/conftest.py. The fixture could resolve the path with snapshot_download(..., local_files_only=True) inside try/except instead of hand-globbing the HF cache layout. Also, os.environ.setdefault("HF_HUB_OFFLINE", "1") in the module-scoped fixtures is never undone and leaks into later tests in the same process. Use with pytest.MonkeyPatch.context() as mp: mp.setenv(...) with a yield fixture.

Comment thread egomimic/models/hpt_nets.py Outdated
def pretrained_reference_state_dict(self) -> Optional[dict]:
"""The snapshot's own safetensors, cast exactly the way
``from_pretrained(torch_dtype=...)`` casts them."""
if not os.path.isdir(self._snapshot_dir):

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.

low (error-handling): When HF is online, _snapshot_dir is the repo id ('Qwen/Qwen3-Embedding-0.6B'), so this returns None and verify_pretrained_weights skips the encoder silently. The 'on by default' check therefore only runs offline. Resolve the cached snapshot regardless of offline mode, for example snapshot_download(model_name, local_files_only=True) (the files are cached after from_pretrained), and log a warning in verify_pretrained_weights whenever a PretrainedWeights module is skipped.

Comment thread egomimic/models/hpt_nets.py Outdated
entry = self._text_feature_cache.get(prompt)
# Entries computed on another device are stale (the module moved):
# drop them rather than silently copy across devices.
if entry is not None and entry.device != device:

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.

nit (simplification): The _apply override already clears the cache on every device or dtype move, so this per-entry entry.device != device check and its comment cannot trigger. Remove it and rely on _apply.

Comment thread egomimic/models/hpt_nets.py Outdated
compared with that reference once the model is fully built.
"""

_hpt_pretrained = True

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.

nit (dead-code): _hpt_pretrained = True is never read; detection goes through pretrained_submodules and pretrained_reference_state_dict. Remove it. Since the mixin now exists, pretrained_module_ids and verify_pretrained_weights can use isinstance(module, PretrainedWeights) instead of duck-typed getattr.

Comment thread egomimic/models/hpt_nets.py Outdated
)
live_hash = hash_state_dict(current, dtype)
ref_hash = hash_state_dict(reference, dtype)
label = name or root.__class__.__name__

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.

nit (simplification): Compute label at the top of the loop body and use it in both raises. The "This runs during HPT.init, before any checkpoint restore" sentence couples a generic helper to one caller, so move it to a comment at the call site in hpt.py.

Comment thread egomimic/models/hpt_nets.py Outdated
# Pretrained-weight protection
#
# ``HPTModel.finalize_modules`` runs a xavier pass (``_init_weights``) over the
# whole model once the stems / heads are ModuleDicts. That pass used to visit

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.

nit (comments): This banner narrates the bug that was fixed ("That pass used to visit...", "without touching finalize_modules again"); that history belongs in the commit message. A one-line description is enough, e.g. "Modules owning pretrained weights declare them so init passes skip them and the weights can be hash-verified". Likewise, the ~1.1 GB / ~3 s figures in hpt.py and the per-entry size table in the text_cache_max_entries docstring can shrink to an order-of-magnitude note.

Comment thread egomimic/models/hpt_nets.py Outdated
its size follows the prompt: 4-25 KB for a 2-12 token prompt at
1024-dim fp16, up to max_length * D * 2 bytes (262 KB at
max_length 128). The 8192 default is therefore ~100-200 MB of
device memory on real annotations and 2 GB in the worst case.

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.

nit (documentation): nets.float() upcasts the encoder, so cached rows are fp32 and the per-entry and total figures here (and in the YAML comments) are 2x too low: about 4 GB in the worst case at 8192 entries x 128 tokens x 1024 dims. Either fix the numbers, or store entries as .to(self._load_dtype) at the cache write and cast back on read to keep the documented size.

RyanPCo and others added 2 commits October 30, 2026 18:45
… padding

`HPTModel.finalize_modules` ran `self.apply(self._init_weights)` over the whole
model, which xavier-randomised the pretrained HF encoders inside the text stems
(the frozen Qwen3-Embedding weights were overwritten in place and their biases
zeroed). Modules that own pretrained weights now declare them through the
`PretrainedWeights` mixin, and the init pass walks the tree without ever
entering such a subtree, so the ResNet backbone, `T5Encoder` and any future
pretrained stem are covered by the same mechanism.

`HPT.__init__` also called `finalize_modules` twice; the first call is gone.

Once the model is built, moved and upcast, `verify_pretrained_weights` hashes
every declared pretrained submodule and compares it with the checkpoint read
straight off the local HF snapshot, failing the run on a mismatch. It costs a
second full copy of the encoder (~1.1 GB fp16 at 0.6B) and ~3 s of hashing per
rank, so `verify_pretrained: false` turns it off; it is on by default, because
a silently re-initialised encoder trains to a plausible wrong result. Only
stems that own an HF snapshot are checked -- `ResNet` returns no reference, so
it is protected from the init pass but not hash-verified, which the mixin
docstring now says.

Finally, `QwenPerTokenEncoder.compute_latent` now passes the tokenizer
attention mask into its cross-attention. Padded positions are zeroed before the
biased `proj`, so they were the learned constant `proj.bias`, not zero, and got
real attention weight: text-conditioned results from before this commit are not
comparable with results after it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VBmhpVy7ZcMtsBJWh38cJp
The annotation vocabulary is tiny next to the number of training steps, so a
frozen text encoder re-runs the same 600M-parameter forward every step.
`_Qwen3BaseEncoder` now keeps a lazy in-memory cache of the per-token hidden
states keyed by the exact prompt string: misses go through the tokenizer +
encoder in ONE sub-batch of unique strings, their un-padded (L_i, D) rows are
stored detached, and the batch is re-assembled with the same LEFT padding the
tokenizer produces, so `forward_with_mask` / the pooled path are unchanged.

Only legal when the encoder is frozen - a trainable encoder must stay online.
Knobs on the stem: `cache_text_features` (default true) and
`text_cache_max_entries` (default 8192, LRU); both added to the model yamls
with a Qwen stem. The cap counts entries, and an entry is the prompt's
un-padded (L, D) states -- 4-25 KB for real annotations, 262 KB at
max_length 128 -- so the default is ~100-200 MB of device memory in practice
and 2 GB in the worst case; the docstring says so next to the knob. A batch
carrying more unique prompts than the cap reads its rows from the miss pass
rather than from the cache, because the LRU trim runs first and would
otherwise evict entries the very next line looks up. `_apply` (to/cuda/float) clears the cache, and
`clear_text_feature_cache()` / `text_feature_cache_stats()` are exposed.

Padded positions come back as zeros instead of the encoder's pad-token output;
every consumer masks them out. Cached-vs-online on 50 real prompts (fp16, each
warmed alone: worst-case padding mismatch): max |diff| 3.6e-3 on the latents
and 9.4e-2 on raw hidden states, exactly equal to the online path's own
batch-padding noise (9.4e-2); in fp32 the same comparison is 1.1e-6 / 1.6e-4.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VBmhpVy7ZcMtsBJWh38cJp
@RyanPCo
RyanPCo force-pushed the ryanco/text-encoder-fixes branch from d429a6a to 8768ba7 Compare October 30, 2026 23:05
@RyanPCo

RyanPCo commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Review findings: status after the fixup pass. The fixes are squashed into this PR's commits, and the whole stack was force-pushed.

Fixed

  • Cache invalidation on device change is implemented twice: Removed the per-entry device check from _encode. The _apply override is now the only place the cache is cleared.
  • _hpt_pretrained = True is never read: Removed the attribute. pretrained_module_ids and verify_pretrained_weights now use isinstance(module, PretrainedWeights).
  • verify_pretrained_weights computes its label late and its generic error text names HPT.init: label is computed once at the top of the loop and used in both raises. The HPT.init remark is now a comment at the call site in hpt.py.
  • Module banner and HPTModel comment narrate the bug history: The banner is now one line. The ~1.1 GB / ~3 s figures in hpt.py and the KB/MB size table in the cache docstring are replaced by order-of-magnitude wording.
  • Pretrained-weight verification is silently skipped whenever HF is online: When the stem has a repo id instead of a local path, pretrained_reference_state_dict now finds the cached snapshot with snapshot_download(local_files_only=True). verify_pretrained_weights prints a yellow line for every module it cannot verify. Added a unit test using a mocked HF cache.
  • Text-cache memory estimate assumes fp16, but the encoder runs in fp32 after nets.float(): Corrected the docstring to fp32 sizes: about 0.5 MB per entry at max_length 128 and about 4 GB worst case. Storage dtype is unchanged, so cached and online outputs still match. The YAML comments contain no size figures.

Partial

  • Qwen snapshot discovery and cross-attn spec helpers are duplicated across the two new test files: Added a qwen_snapshot fixture in tests/unit/conftest.py that uses snapshot_download(local_files_only=True). HF_HUB_OFFLINE is now set with a MonkeyPatch scoped to the stem load. requires_qwen and the cache globbing are gone. The 12-line _cross_attn_specs helper is still duplicated: moving it into a fixture would mean threading a parameter through about 12 tests.

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.

1 participant