Repository navigation
Add V-JEPA 2.1 inference support - #45497
davevanveen wants to merge 46 commits into
Conversation
extend VJEPA2Config with backward-compatible fields for 2.1: use_rope_interleave, use_modality_embeddings, interpolate_rope, return_all_tokens, img_temporal_dim_size, teacher_embed_dim, n_output_distillation, hierarchical_layers. modeling changes: - rope interleave toggle (repeat vs repeat_interleave) - rope position interpolation for flexible resolution - modality embeddings (img_mod_embed/video_mod_embed) - separate image patch embedding (tubelet_size=1) - hierarchical feature extraction with per-layer norms_block - multi-layer predictor embed (mlp when n_output_distillation > 1) - predictor proj_context for return_all_tokens - teacher_embed_dim-based output projection sizing
add four 2.1 variants (vit_base, vit_large, vit_giant, vit_gigantic) with correct configs including hierarchical_layers, teacher_embed_dim, n_output_distillation, and all 2.1-specific flags. handle 2.1 key remappings: patch_embed_img, norms_block, modality embeddings, predictor_proj_context, and ema_encoder checkpoint key.
- config defaults test ensuring 2.0 backward compatibility - fast forward pass test with tiny 2.1 config (n_output_distillation=1) - fast forward pass test with multi-layer distillation (n_output_distillation=4) - slow integration smoke test for 2.1-like config
document four 2.1 pretrained checkpoints, key architectural differences from 2.0, and note distilled variants.
- fix rope interpolation: use pretrained_grid_size (256/patch_size) as numerator, not current grid_size. disable for predictor attention. - fix predictor proj output dim: use n_hier (all hierarchical layers) not n_dist for projection size - add predictor modality embeddings (separate from encoder's) - add predictor modality embedding key mapping in converter - pass is_predictor flag through attention to disable rope interpolation verified against meta reference: encoder max diff 0.0001, predictor max diff 0.008 (sdpa vs eager precision).
raise ValueError when n_output_distillation > 1 since the pooler and classifier expect hidden_size but hierarchical models produce hidden_size * n_dist. no 2.1 classification checkpoints exist yet.
for hierarchical models (n_output_distillation > 1), the encoder now returns norms_block[-1](x) when skip_predictor=True, matching meta's default inference behavior. concatenated features are only returned when the predictor needs them.
…tion determine is_image at VJEPA2Model.forward level by comparing pixel_values_videos.shape[1] to config.img_temporal_dim_size. pass explicit boolean to predictor to select img_mod_embed vs video_mod_embed. previously always used video_mod_embed.
replace config.to_dict() spread with copy.copy(config) to avoid potential issues with @strict decorator rejecting metadata keys.
remove unused VJEPA2_1_CHECKPOINT_KEYS dict, add comment noting provisional hub model names, add pretrained_grid_size comment.
for 2.1 models with return_all_tokens=True, meta's predictor returns (target, context) tuple while hf returns [context, target] concatenated. compare slices separately with atol=1e-2 for sdpa precision. set encoder.return_hierarchical=True for n_dist>1 models.
verify encoder and predictor output shapes for both n_dist=1 (single-norm, teacher_embed_dim projection) and n_dist=4 (concatenated hierarchical, hidden_size projection) paths.
|
Hello @davevanveen ! Thanks a lot for opening this PR. Sorry for the delay in reviewing this, we have been very busy with lots of new models to review and recent core changes. I'll try to get to this PR next week! |
|
Appreciate the note, @yonigozlan! No rush from me; I imagine y'all are pretty slammed. Thanks for all the work you do. Cheers. |
yonigozlan
left a comment
There was a problem hiding this comment.
Hey @davevanveen ! Thanks a lot for working on this :), I have a few comments, mainly on possible simplifications, and I also think we need a better API to make sure the outputs are coherent between VJEPA2 and 2.1
| img_config = copy.copy(config) | ||
| img_config.tubelet_size = 1 | ||
| self.patch_embeddings_img = VJEPA2PatchEmbeddings3D(img_config, hidden_size=hidden_size) |
There was a problem hiding this comment.
Let's add a tubelet_size arg to VJEPA2PatchEmbeddings3D instead of this workaround, and take the config value when the arg is not set.
| # then embedding lookup fails. In these cases, we duplicate the frames. | ||
| if num_frames < self.config.tubelet_size: | ||
| pixel_values_videos = pixel_values_videos.repeat(1, 1, self.config.tubelet_size, 1, 1) | ||
| is_image = self.config.img_temporal_dim_size is not None and num_frames == self.config.img_temporal_dim_size |
There was a problem hiding this comment.
Is config.img_temporal_dim_size ever something other than 1? otherwise let's change img_temporal_dim_size to a boolean flag in the config like use_image_patch_embedder and simplify this logic
There was a problem hiding this comment.
Confirmed it's always 1. Renamed to use_image_patch_embedder: bool (default False) and simplified to is_image = self.config.use_image_patch_embedder and num_frames == 1.
| config: VJEPA2Config, | ||
| hidden_size: int = 1024, | ||
| num_attention_heads: int = 16, | ||
| is_predictor: bool = False, |
There was a problem hiding this comment.
Looks like is_predictor is only used to interpolate rope or not, so let's use an interpolate_rope flag instead (which would override the config one if specified)
| # Remove frame component from ids (1st term) and height component (2nd term) | ||
| width_ids = (ids - tokens_per_frame * frame_ids) - tokens_per_row * height_ids | ||
|
|
||
| if self.config.interpolate_rope and not self.is_predictor and self.grid_size > 1: |
There was a problem hiding this comment.
We would only need this then:
| if self.config.interpolate_rope and not self.is_predictor and self.grid_size > 1: | |
| if self.interpolate_rope and self.grid_size > 1: |
| When set, creates a separate image patch embedding with `tubelet_size=1`. | ||
| teacher_embed_dim (`int`, *optional*, defaults to `None`): | ||
| Teacher embedding dimension for distilled models. Controls predictor output projection size. | ||
| n_output_distillation (`int`, *optional*, defaults to 0): |
There was a problem hiding this comment.
Let's call this num_distillation_outputs, and set it to 1 by default, so that we don't need the "> 0" check in the modeling code (no need to have a redundant condition when hierarchical_layers is set)
There was a problem hiding this comment.
Done. Renamed to num_distillation_outputs with default 1. Also took the opportunity to rename return_all_tokens to use_context_projection, since after the predictor refactor the old name described deleted behavior (the concatenation into last_hidden_state). Happy to revert if you'd rather keep return_all_tokens.
| N_ctxt = context_mask[0].shape[1] | ||
| hf_context = predictor_outputs.last_hidden_state[:, :N_ctxt] | ||
| hf_target = predictor_outputs.last_hidden_state[:, N_ctxt:] | ||
| assert torch.allclose(hf_target, og_target, atol=1e-2) | ||
| assert torch.allclose(hf_context, og_context, atol=1e-2) |
There was a problem hiding this comment.
This is not the ideal API imo, We could instead change VJEPA2WithMaskedInputPredictorOutput in the modeling code to add a context_predictions field so that, we'll have:
| N_ctxt = context_mask[0].shape[1] | |
| hf_context = predictor_outputs.last_hidden_state[:, :N_ctxt] | |
| hf_target = predictor_outputs.last_hidden_state[:, N_ctxt:] | |
| assert torch.allclose(hf_target, og_target, atol=1e-2) | |
| assert torch.allclose(hf_context, og_context, atol=1e-2) | |
| assert torch.allclose(predictor_outputs.last_hidden_state, og_target, atol=1e-2) | |
| assert torch.allclose(predictor_outputs.context_predictions, og_context, atol=1e-2) |
There was a problem hiding this comment.
Done at both sites. Validated end-to-end against Meta's vit_base_2_1_384 and vit_giant_2_1_384 checkpoints. allclose passes for full-mask and partial-mask cases.
| N_ctxt = context_mask[0].shape[1] | ||
| hf_context = predictor_outputs.last_hidden_state[:, :N_ctxt] | ||
| hf_target = predictor_outputs.last_hidden_state[:, N_ctxt:] | ||
| assert torch.allclose(hf_target, og_target, atol=1e-2) | ||
| assert torch.allclose(hf_context, og_context, atol=1e-2) |
There was a problem hiding this comment.
Same here:
| N_ctxt = context_mask[0].shape[1] | |
| hf_context = predictor_outputs.last_hidden_state[:, :N_ctxt] | |
| hf_target = predictor_outputs.last_hidden_state[:, N_ctxt:] | |
| assert torch.allclose(hf_target, og_target, atol=1e-2) | |
| assert torch.allclose(hf_context, og_context, atol=1e-2) | |
| assert torch.allclose(predictor_outputs.last_hidden_state, og_target, atol=1e-2) | |
| assert torch.allclose(predictor_outputs.context_predictions, og_context, atol=1e-2) |
| else: | ||
| predictor_output = None | ||
|
|
||
| encoder_output = VJEPA2WithMaskedInputModelOutput( |
There was a problem hiding this comment.
We'll also need to add a hierarchical_hidden_state field here as well since it wouldn't be in last_hidden_state anymore
There was a problem hiding this comment.
Done. Added hierarchical_hidden_state to VJEPA2WithMaskedInputPredictorOutput. Also added it to VJEPA2WithMaskedInputModelOutput so users calling VJEPA2Model(..., skip_predictor=True) for feature extraction can still reach hierarchical features when predictor_output is None.
| ) | ||
| predictor_output = VJEPA2WithMaskedInputPredictorOutput( | ||
| last_hidden_state=predictor_outputs.last_hidden_state, | ||
| target_hidden_state=apply_masks(sequence_output, target_mask), |
There was a problem hiding this comment.
Same here (hierarchical_hidden_state if not None, otherwise sequence_output)
| if config.n_output_distillation > 1: | ||
| raise ValueError( | ||
| f"Classification heads for hierarchical distillation outputs " | ||
| f"(n_output_distillation={config.n_output_distillation}) are not yet supported. " | ||
| f"Use VJEPA2Model for feature extraction instead." | ||
| ) |
There was a problem hiding this comment.
Why have this guard here? I think we can get rid of it fully and let users experiment
There was a problem hiding this comment.
Guard removed.
Added a small smoke test (test_classification_head_with_hierarchical_distillation) that instantiates VJEPA2ForVideoClassification with a 2.1-style config (num_distillation_outputs=4, hierarchical_layers set) and runs a forward pass. The pooler consumes last_hidden_state (post-loop, dim hidden_size), not hierarchical_hidden_state, so shapes work out for any num_distillation_outputs.
Documented in the classification head's docstring that for V-JEPA 2.1 the pooler sees unnormalized features. Users wanting hierarchical features should use VJEPA2Model for feature extraction.
|
Hi @yonigozlan - Thanks again for the detailed review! I addressed all requested changes three weeks ago. When you have a chance, I'd appreciate another look. Happy to address any loose ends. Cheers. |
|
Hey @davevanveen ! Me and/or @guarin will follow-up on reviewing the PR soon, sorry for the delay 🤗 |
molbap
left a comment
There was a problem hiding this comment.
Thanks for iterating on this! I added a couple questions + discussion about checkpoint upload. Let me know!
| @slow | ||
| def test_inference_vjepa2_1_base(self): | ||
| """Smoke test: instantiate a 2.1-like config and run forward pass.""" | ||
| config = VJEPA2Config( |
There was a problem hiding this comment.
Since it's small it doesn't need @slow (it just asserts shapes so it does not bring much in current state) but let's add an integration test!
The integration test should be comparing against real outputs taken from the original implementation by Meta.
That way you can push the converted checkpoints to an organisation you own on the hub (still indicating Meta's license) and we can see to move it to their org later on (your authorship/initial upload is preserved). What do you think?
There was a problem hiding this comment.
Removed @slow from test_inference_vjepa2_1_base.
Great idea on the integration test! Curious what you think of this approach.
- Added
test_inference_vjepa2_1_integration- Loads
davevanveen/vjepa2.1-vitb-fpc64-384 - Runs a forward pass on the standard COCO fixture image
- Asserts
hierarchical_hidden_state[:, :4, :4]andpredictor_output.last_hidden_state[:, :4, :4]against values from Meta's original implementation.
- Loads
- Converted
vit_base_2_1_384and pushed todavevanveen/vjepa2.1-vitb-fpc64-384(link). Allallcloseassertions passed.- Note:
context_predictionsrequiredatol=2e-2rather than1e-2. Max diff ~0.014 and mean ~2e-6 suggest a float32 accumulation noise across the predictor layers, not a weight error. I updated converter accordingly. - Happy to transfer to Meta's org as you see fit.
- Note:
There was a problem hiding this comment.
output[-1] will not be VJEPA2WithMaskedInputPredictorOutput but a FloatTensor here
There was a problem hiding this comment.
Fixed. Replaced with the canonical recursive pattern used by CLIP, BLIP-2, etc.:
def to_tuple(self) -> tuple[Any]:
return tuple(v.to_tuple() if isinstance(v, ModelOutput) else v for v in self.values())| argsort = torch.argsort(position_masks, dim=1) # [B, N] | ||
| hidden_states, position_masks = self.sort_tokens(hidden_states, position_masks, argsort) | ||
|
|
||
| if self.config.use_modality_embeddings and hasattr(self.embeddings, "video_mod_embed"): |
There was a problem hiding this comment.
if we use_modality_embeddings we always have video_mod_embed has attribute no?
There was a problem hiding this comment.
Correct. VJEPA2PredictorEmbeddings.__init__ always creates both img_mod_embed and video_mod_embed when use_modality_embeddings=True. Removed the redundant hasattr guard.
| def _is_2_1_model(model_name): | ||
| return "2_1" in model_name |
There was a problem hiding this comment.
does not warrant a function
There was a problem hiding this comment.
Removed the helper entirely. All three call sites now inline "2_1" in model_name directly.
There was a problem hiding this comment.
On that, see my comment on tests about uploading the converted checkpoints to the hub!
| target_predictions = self.proj(hidden_states[:, N_ctxt:]) | ||
| context_predictions = self.proj_context(hidden_states[:, :N_ctxt]) if self.proj_context is not None else None |
There was a problem hiding this comment.
let's use the occasion to get rid of namings like N_ctxt (we can afford full/explicit wording)
There was a problem hiding this comment.
Renamed to num_context_tokens throughout VJEPA2Predictor.forward. Also replaced the now-unused D dimension with _.
| if context_mask is None and target_mask is None: | ||
| B = pixel_values_videos.size(0) | ||
| N = sequence_output.size(1) # ensure we are using dynamic patch size | ||
| N = sequence_output.size(1) |
There was a problem hiding this comment.
why delete this, not true anymore?
There was a problem hiding this comment.
Restored. You're right, better to clarify: N is defined at runtime to support variable-resolution inputs.
…tore dynamic-N comment
|
Hi @molbap, appreciate your review! Great suggestions; all are addressed above. Happy to continue the discussion if anything looks off. |
molbap
left a comment
There was a problem hiding this comment.
Sorry for the delay, summer is busy. Thanks for iterating!
| self.img_mod_embed = nn.Parameter(torch.zeros(1, 1, hidden_size)) | ||
| self.video_mod_embed = nn.Parameter(torch.zeros(1, 1, hidden_size)) |
There was a problem hiding this comment.
these are not present in init_weights, and should be I think since they are additive
There was a problem hiding this comment.
Done. Added normal_(std=1e-6) initialization for both img_mod_embed and video_mod_embed in _init_weights, covering both VJEPA2Embeddings (encoder) and VJEPA2PredictorEmbeddings (predictor). Used std=1e-6 to match Meta's original implementation, which allocates the parameter as zeros and immediately applies nn.init.normal_(..., std=1e-6).
| hierarchical_hidden_state=hierarchical_hidden_state, | ||
| ) | ||
|
|
||
| return encoder_output | ||
|
|
||
| def get_vision_features(self, pixel_values_videos) -> torch.Tensor: | ||
| encoder_output = self.forward(pixel_values_videos, skip_predictor=True) | ||
| return encoder_output.last_hidden_state |
There was a problem hiding this comment.
we should return hierarchical_hidden_state when it exists, right?
There was a problem hiding this comment.
Done. get_vision_features now returns hierarchical_hidden_state when non-None, falling back to last_hidden_state otherwise.
|
|
||
| # for vjepa2.1 models, the encoder output to compare is hierarchical_hidden_state | ||
| # (mirrors the converter's validation: hf_encoder = outputs.hierarchical_hidden_state ?? outputs.last_hidden_state) | ||
| # reference values from Meta's original implementation — see workstream_001_build.md |
There was a problem hiding this comment.
this file isn't in the repo (and should not be), let's use Expectations instead of raw values here
There was a problem hiding this comment.
Done. Wrapped both expected slices in Expectations({(None, None): [...]}) and switched to torch.testing.assert_close. Removed the stale comment.
| target_dtype = self.patch_embeddings.proj.weight.dtype | ||
| pixel_values_videos = pixel_values_videos.to(dtype=target_dtype) | ||
| embeddings = self.patch_embeddings(pixel_values_videos) | ||
| if is_image and self.patch_embeddings_img is not None: |
There was a problem hiding this comment.
is_image already requires config.use_image_patch_embedder no?
There was a problem hiding this comment.
Correct. Removed the redundant guard. is_image already requires use_image_patch_embedder=True, which is the same condition under which patch_embeddings_img is created, so the is not None check is unreachable.
| # matches Meta's hardcoded RoPE reference resolution (256 for patch_size=16) | ||
| self.pretrained_grid_size = 256 // self.config.patch_size |
There was a problem hiding this comment.
this should go into the configuration, in the recent rope_parameters
There was a problem hiding this comment.
Done. Added pretrained_grid_size: int | None = None to VJEPA2Config alongside the existing RoPE fields (use_rope_interleave, interpolate_rope). Falls back to 256 // patch_size when unset, preserving existing behavior.
An alternative to the flat-field approach above would be adopting the rope_parameters: dict | None = None dict pattern used by other models and place pretrained_grid_size inside it. VJEPA2's custom RoPE doesn't use the shared ROPE_INIT_FUNCTIONS pipeline, so the dict would be purely a namespace here. Happy to switch if you prefer.
| width_ids = (ids - tokens_per_frame * frame_ids) - tokens_per_row * height_ids | ||
|
|
||
| if self.interpolate_rope and self.grid_size > 1: | ||
| h_scale = (self.pretrained_grid_size - 1.0) / max(self.grid_size - 1.0, 1.0) |
There was a problem hiding this comment.
same value for h_ and w_ scale, one suffice
There was a problem hiding this comment.
Done. Collapsed to a single scale variable.
| @slow | ||
| def test_inference_vjepa2_1_integration(self): | ||
| """Integration test: load vjepa2.1-vitb from Hub and compare encoder output against Meta's original implementation.""" | ||
| model = VJEPA2Model.from_pretrained("davevanveen/vjepa2.1-vitb-fpc64-384").to(torch_device) |
There was a problem hiding this comment.
let's settle that before merge :) I think we can open a hub PR
There was a problem hiding this comment.
Happy to open a Hub PR from davevanveen/vjepa2.1-vitb-fpc64-384 into the facebook org. The converted checkpoint is ready. The prerequisite is that the destination repo exists first.
@molbap and/or @koustuvsinha - would you be able to create a repo for the V-JEPA 2.1 checkpoints, e.g. facebook/vjepa2.1-vitb-fpc64-384? Once that's done, I will open the Hub PR.
Note that community ports are already appearing (e.g. the collection referenced in this issue). An official checkpoint under the facebook org would give users a clear canonical source.
| expected_shape = torch.Size((1, num_masks, 1024)) | ||
| self.assertEqual(outputs.predictor_output.last_hidden_state.shape, expected_shape) | ||
|
|
||
| def test_inference_vjepa2_1_base(self): |
There was a problem hiding this comment.
this is same as test_model_2_1_forward and is a weak test (only test not-None), I think we can delete
There was a problem hiding this comment.
Done. Deleted test_inference_vjepa2_1_base.
| use_context_projection: bool = False | ||
| use_image_patch_embedder: bool = False | ||
| teacher_embed_dim: int | None = None | ||
| num_distillation_outputs: int = 1 |
There was a problem hiding this comment.
for the issue where num_distillation_outputs > len(hierarchical_layers) you can add a strict validation in the config
There was a problem hiding this comment.
Done. Added a __post_init__ validation that raises ValueError when num_distillation_outputs > len(hierarchical_layers). No-op when hierarchical_layers is None (V-JEPA 2.0 configs).
… guard, collapse scale
…ion_outputs validation
…lback and config validation
|
Hi @molbap @yonigozlan - Everything is passing and ready on my end. Let me know if you’d like any further changes before we merge. Thanks. |
|
[For maintainers] Suggested jobs to run (before merge) run-slow: vjepa2 |
CI recapDashboard: View test results in Grafana |
What does this PR do?
Adds inference support and checkpoint conversion for Meta V-JEPA 2.1 pretrained backbones within the existing
vjepa2model family.V-JEPA 2.1 was released by Meta on 2026-03-16 with four pretrained encoders at 384 resolution: ViT-B (80M), ViT-L (300M), ViT-g (1B), and ViT-G (2B). This PR extends the existing
VJEPA2Modelwith backward-compatible config fields and modeling changes to support loading these checkpoints.Changes
Config (
configuration_vjepa2.py): 8 new fields with backward-compatible defaults that preserve V-JEPA 2.0 behavior:use_rope_interleave,use_modality_embeddings,interpolate_rope,return_all_tokens,img_temporal_dim_sizeteacher_embed_dim,n_output_distillation,hierarchical_layersModeling (
modeling_vjepa2.py):repeat_interleavevsrepeat) with config toggletubelet_size=1) whenimg_temporal_dim_sizeis setnorms_blockin encodern_output_distillation > 1), single-norm output forget_vision_features()/skip_predictor=Truen_output_distillation > 1proj_context) forreturn_all_tokensVJEPA2ForVideoClassificationguard against unsupported hierarchical configsConverter (
convert_vjepa2_to_hf.py):ema_encoderfor distilled models,target_encoderfor self-supervised)Tests (
test_modeling_vjepa2.py):n_output_distillation=1(distilled path)n_output_distillation=4(multi-layer path)Docs (
vjepa2.md): V-JEPA 2.1 section with checkpoint table and architecture notes.Verification
Verified end-to-end against Meta's reference implementation on two checkpoints covering both architecture paths:
ViT-B/384 (80M,
n_output_distillation=1, distilled):ViT-g/384 (1B,
n_output_distillation=4, self-supervised):All diffs are within SDPA floating-point precision.
2.1 checkpoint summary
n_output_distillationteacher_embed_dimFixes #45496
Code Agent Policy
AI assistance (Claude Code) was used for implementation. I reviewed and verified all changes, ran tests, and validated checkpoint conversion end-to-end on both architecture paths (ViT-B and ViT-g).
Before submitting
Pull Request section?
Who can review?
@yonigozlan — vision models reviewer and original V-JEPA 2 contributor
@molbap — vision models reviewer