Add custom MLP vision projector and tests - #4593
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
| # If vision_projector_type is explicitly set to 'customized_mlp', | ||
| # override the model's default projector with a custom MLP-based projector. | ||
| vision_projector_type_config = getattr(self.config, "vision_projector_type", "default") | ||
| if vision_projector_type_config == "customized_mlp": |
There was a problem hiding this comment.
Maybe change this name to "customized_vision_projector"?
Because later we will have audio components and prefer explicit naming pattern.
There was a problem hiding this comment.
Updated - I also changed the names in PR description accordingly
| without naming the weight. | ||
| """ | ||
| problems = _weight_mismatches(want, have) | ||
| problems = [(p, why) for p, why in problems if not _is_custom_vision_projector_problem(p, want)] |
There was a problem hiding this comment.
Add a one-line comment explaining this line, such as "if the problem is caused by customized projector, ignore/remove this problem" if I understand correctly.
| return want, have | ||
|
|
||
|
|
||
| def _is_custom_vision_projector_problem(path: str, want: dict) -> bool: |
There was a problem hiding this comment.
Rename to _is_custom_projector_problem to be more generic? (we could reuse for audio projector too in the future)
There was a problem hiding this comment.
Agree - updated the code
|
|
||
| cfg = _make_config( | ||
| enable_checkpointing=True, | ||
| load_parameters_path="gs://fake/custom_vision_ckpt", |
There was a problem hiding this comment.
What is this "gs://fake/custom_vision_ckpt"? If it's actually unused, can we possibly remove it from the config?
There was a problem hiding this comment.
It is a dummy path required to test checkpoint loading related functions. The same flag was passed in many other unit tests (e.g., L691). I was wondering why this is added throughout the unit tests so I added some print statements in the _free_device_memory during debugging, and without this load_parameters_path="gs://fake/custom_vision_ckpt", _free_device_memory never gets executed in unit tests.
There was a problem hiding this comment.
maybe use enable_checkpointing=False?
| return embeddings, deep_feats | ||
|
|
||
|
|
||
| class MultimodalMLPProjector(nnx.Module): |
There was a problem hiding this comment.
I like this customizable block! Is it possible to reuse for audio tower in the future too? Asking because you named it Multimodal instead of Vision, which is great.
There was a problem hiding this comment.
Yes, one just need to 1. define the input/output dimensions based on the audio config (L146), 2. define the connector hyperparameters (similar to L150-155), and 3. wire it up in AudioEncoder._setup_audio_encoder_layers
This should be very straightforward
| return node | ||
|
|
||
| jax.tree_util.tree_map(_free_device_memory, sharded_state, is_leaf=lambda n: isinstance(n, nnx.Variable)) | ||
| jax.tree_util.tree_map_with_path(_free_device_memory, sharded_state, is_leaf=lambda n: isinstance(n, nnx.Variable)) |
There was a problem hiding this comment.
Why is this change needed?
There was a problem hiding this comment.
Standard tree_map() passes only the raw tensor (node). A tensor has no name so it's very difficult to distinguish a base model weight from a custom projector weight. We want to identify the custom projector layer so it doesnt get deleted during free memory. So I switched to tree_map_with_path() which passes pytree keys (like "custom_linear_0"), and I can filter by the name, skip freeing memory in custom_linear, and keep it randomly initialized
reference: https://docs.jax.dev/en/latest/_autosummary/jax.tree.map_with_path.html
| proj_name = parts[1] | ||
| proj_dict = want.get("vision_encoder", {}).get(proj_name, {}) | ||
| if isinstance(proj_dict, dict) and any("custom_linear" in k for k in proj_dict.keys()): | ||
| print( |
| import jax.numpy as jnp | ||
|
|
||
| # Ensure the parent directory (src/) is in sys.path so we can import maxtext | ||
| sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))) |
There was a problem hiding this comment.
I don't think we need this. Is the maxtext installed from source properly? Follow here
| num_blocks = seq_len // self.tokens_per_block | ||
| x = x.reshape((batch_size, num_blocks, self.tokens_per_block * in_dim)) | ||
|
|
||
| for i in range(self.num_layers): |
There was a problem hiding this comment.
in JAX we usually use jax.lax.scan instead of for-loop for faster compilation and potentially better performance
| use_multimodal=True, | ||
| model_name="qwen3-vl-2b", | ||
| override_model_config=True, | ||
| scan_layers=False, |
There was a problem hiding this comment.
why the scan is not used?
Description
This PR adds support for configurable custom vision projectors in MaxText's multimodal models. It introduces new vision projector config options, handles checkpoint weight mismatches when attaching custom projector architectures to pre-trained base models, and adds a test script to verify projector initialization and embedding extraction.
Step 2 of a 5-step Proof-of-Concept for any-to-any multimodal alignment in MaxText. Overall goal is to align a pretrained vision encoders with another text-only LLM, and connecting the two with a dynamically configured adaptation layer (customized MLP connector), special tokenizer mapping for visual placeholder tokens, and train only the MLP using supervised fine tuning.
Changes
Custom MLP Projector Module
src/maxtext/layers/encoders.pyMultimodalMLPProjector(nnx.Module), supporting configurable layer, activations, hidden size, and layer biases.VisionEncoder._setup_vision_encoder_layers()to conditionally override the default vision projector withMultimodalMLPProjectorwhenevervision_projector_type == "customized_vision_projector".Config
src/maxtext/configs/types.pyVisionProjectorconfiguration class for customizing projector architecture.Checkpoint Loading & Weight Mismatch Handling
src/maxtext/common/checkpointing.py_is_custom_projector_problem()to check if missing/mismatched weight keys belong to custom vision projector layers._raise_on_weight_mismatch()to suppress weight mismatch errors for custom projector layers only, and raise a warning instead. This allows restoring base model parameters from standard checkpoints while leaving newly introduced custom projector weights randomly initialized.src/maxtext/utils/model_creation_utils.py_free_device_memoryinsidefrom_pretrained().is_custom) to skip deleting JAX arrays corresponding to custom layers (i.e., layers with "custom_linear" in their name).Test Script
src/maxtext/experimental/omni_poc/tests/custom_vision_projector_test.pyqwen3-vl-2b) with a customized MLP vision projector.tests/unit/model_creation_utils_test.pyExample Usage
Tests
1. Unit Tests
Command:
Output:
Command:
Output:
2. Custom Vision Projector Verification
Validates that vision embeddings are:
Command:
python3 src/maxtext/experimental/omni_poc/tests/custom_vision_projector_test.py \ --load_parameters_path=<your-path> \ --hf_access_token=<your-token>Output:
3. End-to-End Multimodal Decoding Checks
A. Baseline Multimodal Run (Default Vision Projector)
Verifies standard multimodal inference functions normally.
Command:
python3 -m maxtext.inference.decode \ src/maxtext/configs/base.yml \ model_name=qwen3-vl-2b \ tokenizer_path=Qwen/Qwen3-VL-2B-Instruct \ tokenizer_type=huggingface \ load_parameters_path=<your-path> \ per_device_batch_size=1 \ scan_layers=false \ use_multimodal=true \ prompt='Describe this image' \ image_path='tests/assets/test_image.jpg' \ max_prefill_predict_length=512 \ max_target_length=768 \ ici_tensor_parallelism=4 \ override_model_config=true \ attention='dot_product' \ hf_access_token=<your-token>Output:
B. Custom Vision Projector Pipeline Run
Verifies end-to-end data flow with custom projector flags (
customized_vision_projector). Output text is bad due to random weight initialization, as expected.Command:
python3 -m maxtext.inference.decode \ src/maxtext/configs/base.yml \ model_name=qwen3-vl-2b \ tokenizer_path=Qwen/Qwen3-VL-2B-Instruct \ tokenizer_type=huggingface \ load_parameters_path=<your-path> \ per_device_batch_size=1 \ run_name=runner_image_2026-06-26-20-40 \ scan_layers=false \ use_multimodal=true \ prompt='Describe this image' \ image_path='tests/assets/test_image.jpg' \ max_prefill_predict_length=512 \ max_target_length=768 \ ici_tensor_parallelism=4 \ override_model_config=true \ attention='dot_product' \ hf_access_token=<your-token> \ vision_projector_type=customized_vision_projector \ vision_connector_num_layers=2 \ vision_connector_activation=gelu \ vision_connector_use_bias=trueOutput:
4. Text-Only Decoding Regression Check
Verifies custom vision projector configurations do not affect text-only inference mode (
use_multimodal=false).Command:
python3 -m maxtext.inference.decode \ src/maxtext/configs/base.yml \ model_name=qwen3-vl-2b \ tokenizer_path=Qwen/Qwen3-VL-2B-Instruct \ tokenizer_type=huggingface \ load_parameters_path=<your-path> \ per_device_batch_size=1 \ max_prefill_predict_length=128 \ max_target_length=256 \ scan_layers=false \ use_multimodal=false \ prompt='How many times does the letter r appear in the word Strawberry?' \ override_model_config=true \ ici_fsdp_parallelism=1 \ ici_tensor_parallelism=-1 \ hf_access_token=<your-token> \ vision_projector_type=customized_vision_projector \ vision_connector_num_layers=2 \ vision_connector_activation=gelu \ vision_connector_use_bias=trueOutput:
Step-by-Step Objectives
√Multi-Directory Checkpoint Restoration: Implemented selective sub-tree parameter loading using Orbax such that we can initiaze a multimodal model with parameters from multiple checkpoints.thisDynamic MLP Connector: Add omni modal adapter layer (MLP) to connect vision tower output to the LLM decodernextSpecial Tokenizer & Placeholder Masking: Add special token <|image|> to tokenizer and make sure the masking is correct in the decoder.Checklist
Before submitting this PR, please make sure (put X in square brackets):
gemini-reviewlabel.