add glm5.2 indexshare - #4832
Conversation
…store pristine deepseek.py
…branches, and FLOP calculation
… layers to donor layer indexers for GLM-5.2
…available checkpoint keys
…ayer_index_reuse for XProf/XPlane profiling
…or abstract scanned layers
…full 78-layer forward and backward passes
There was a problem hiding this comment.
Code Review
This pull request introduces support for the GLM-5.2 model, specifically implementing cross-layer IndexShare (IndexCache) for Dynamic Sparse Attention. The changes include new model configurations, layer definitions, and utility functions for managing IndexShare patterns. The reviewer provided critical feedback to improve the robustness of the implementation, specifically suggesting the replacement of hardcoded donor layer logic in checkpoint conversion and attention layers with dynamic lookups, as well as addressing potential indexing errors and log spam.
| if "indexer" in str(hf_key_single) and str(hf_key_single).startswith("model.layers."): | ||
| import re | ||
|
|
||
| m = re.match(r"model\.layers\.(\d+)\.(.+)", str(hf_key_single)) | ||
| if m: | ||
| rest = m.group(2) | ||
| donor_key = f"model.layers.0.{rest}" | ||
| try: | ||
| hf_tensor_numpy = tensor_getter_fn(donor_key) | ||
| except Exception: | ||
| hf_tensor_numpy = np.zeros(mt_slice_shape, dtype=np.float32) | ||
| else: | ||
| raise e | ||
| else: | ||
| raise e |
There was a problem hiding this comment.
The current implementation hardcodes model.layers.0 as the donor layer for all shared layers. In GLM-5.2, there are multiple Full (F) layers (e.g., 0, 4, 8, 12, ...) acting as donors. Hardcoding layer 0 means higher shared layers (like 5, 6, 7) will incorrectly reuse layer 0's indexer weights instead of their actual donor layer (layer 4), leading to incorrect attention routing and degraded model quality.
We can resolve this by dynamically searching backwards for the closest preceding layer that contains the indexer weights. This is extremely robust and doesn't require access to the config object.
| if "indexer" in str(hf_key_single) and str(hf_key_single).startswith("model.layers."): | |
| import re | |
| m = re.match(r"model\.layers\.(\d+)\.(.+)", str(hf_key_single)) | |
| if m: | |
| rest = m.group(2) | |
| donor_key = f"model.layers.0.{rest}" | |
| try: | |
| hf_tensor_numpy = tensor_getter_fn(donor_key) | |
| except Exception: | |
| hf_tensor_numpy = np.zeros(mt_slice_shape, dtype=np.float32) | |
| else: | |
| raise e | |
| else: | |
| raise e | |
| if "indexer" in str(hf_key_single) and str(hf_key_single).startswith("model.layers."): | |
| import re | |
| m = re.match(r"model\.layers\.(\d+)\.(.+)", str(hf_key_single)) | |
| if m: | |
| layer_idx = int(m.group(1)) | |
| rest = m.group(2) | |
| hf_tensor_numpy = None | |
| for d in range(layer_idx - 1, -1, -1): | |
| try: | |
| hf_tensor_numpy = tensor_getter_fn(f"model.layers.{d}.{rest}") | |
| break | |
| except Exception: | |
| continue | |
| if hf_tensor_numpy is None: | |
| hf_tensor_numpy = np.zeros(mt_slice_shape, dtype=np.float32) | |
| else: | |
| raise e | |
| else: | |
| raise e |
| if "indexer" in key and key.startswith("model.layers."): | ||
| import re | ||
|
|
||
| m = re.match(r"model\.layers\.(\d+)\.(.+)", key) | ||
| if m: | ||
| rest = m.group(2) | ||
| donor_key = f"model.layers.0.{rest}" | ||
| try: | ||
| return orig_tensor_getter(donor_key) | ||
| except Exception: | ||
| pass | ||
| raise e |
There was a problem hiding this comment.
Similar to the issue in _build_single_axis_stacked_tensor, hardcoding model.layers.0 as the donor layer for all shared layers is incorrect for GLM-5.2 because there are multiple Full (F) layers acting as donors. We should use the same backward-search logic here to dynamically find the closest preceding Full layer.
| if "indexer" in key and key.startswith("model.layers."): | |
| import re | |
| m = re.match(r"model\.layers\.(\d+)\.(.+)", key) | |
| if m: | |
| rest = m.group(2) | |
| donor_key = f"model.layers.0.{rest}" | |
| try: | |
| return orig_tensor_getter(donor_key) | |
| except Exception: | |
| pass | |
| raise e | |
| if "indexer" in key and key.startswith("model.layers."): | |
| import re | |
| m = re.match(r"model\.layers\.(\d+)\.(.+)", key) | |
| if m: | |
| layer_idx = int(m.group(1)) | |
| rest = m.group(2) | |
| for d in range(layer_idx - 1, -1, -1): | |
| try: | |
| return orig_tensor_getter(f"model.layers.{d}.{rest}") | |
| except Exception: | |
| continue | |
| raise e |
| if layer_idx is not None: | ||
| is_full = (layer_idx % 4 == 0) | ||
| indexer_mask, topk_indices, indexer_score = jax.lax.cond( | ||
| is_full, | ||
| _run_full, | ||
| _run_shared, | ||
| operand=None, | ||
| ) |
There was a problem hiding this comment.
Hardcoding is_full = (layer_idx % 4 == 0) assumes the pattern is always "FSSS" and that the layer indexing aligns perfectly. If a different pattern is configured (e.g., "FSS"), this will be incorrect. We should dynamically look up whether the layer is Full using the self.is_full_array we initialized.
| if layer_idx is not None: | |
| is_full = (layer_idx % 4 == 0) | |
| indexer_mask, topk_indices, indexer_score = jax.lax.cond( | |
| is_full, | |
| _run_full, | |
| _run_shared, | |
| operand=None, | |
| ) | |
| if layer_idx is not None: | |
| is_full = self.is_full_array[layer_idx] | |
| indexer_mask, topk_indices, indexer_score = jax.lax.cond( | |
| is_full, | |
| _run_full, | |
| _run_shared, | |
| operand=None, | |
| ) |
| if getattr(self.config, "use_index_share", False) and self.served_group_size > 1: | ||
| loss_scale = loss_scale / float(self.served_group_size) |
There was a problem hiding this comment.
During scanned execution, self.served_group_size is a static attribute of the template layer, which means it will be constant (e.g., 4) for all layers in the scan. However, the actual group size can vary (e.g., the last group has size 2). We should dynamically look up the group size using self.served_group_size_array to ensure correct loss scaling across all layers.
if getattr(self.config, "use_index_share", False):
group_size = self.served_group_size_array[layer_idx] if layer_idx is not None else float(self.served_group_size)
if group_size > 1:
loss_scale = loss_scale / group_size| matching_layers = [ | ||
| int(k.split(".")[2]) | ||
| for k in hf_state_dict_numpy | ||
| if k.startswith("model.layers.") and k.endswith(f".{rest}") | ||
| ] |
There was a problem hiding this comment.
Using k.split(".")[2] can raise an IndexError if there are any keys in hf_state_dict_numpy starting with model.layers. but having fewer than 3 components (e.g., model.layers). Using a regular expression match is much safer and more robust.
| matching_layers = [ | |
| int(k.split(".")[2]) | |
| for k in hf_state_dict_numpy | |
| if k.startswith("model.layers.") and k.endswith(f".{rest}") | |
| ] | |
| matching_layers = [] | |
| for k in hf_state_dict_numpy: | |
| m_k = re.match(r"model\.layers\.(\d+)\.(.+)", k) | |
| if m_k and m_k.group(2) == rest: | |
| matching_layers.append(int(m_k.group(1))) |
| is_pruned = ( | ||
| getattr(config, "use_index_share", False) | ||
| and getattr(config, "prune_shared_indexers", True) | ||
| and self.is_shared_layer | ||
| ) | ||
| if self.use_indexer and not is_pruned: |
There was a problem hiding this comment.
To support dynamic IndexShare patterns and scanned layers robustly, we should initialize boolean and integer arrays representing whether each layer is Full/Shared and its served group size. This allows us to dynamically index into them using the JAX tracer layer_idx during scanned execution, rather than hardcoding the pattern or assuming static properties.
| is_pruned = ( | |
| getattr(config, "use_index_share", False) | |
| and getattr(config, "prune_shared_indexers", True) | |
| and self.is_shared_layer | |
| ) | |
| if self.use_indexer and not is_pruned: | |
| is_pruned = ( | |
| getattr(config, "use_index_share", False) | |
| and getattr(config, "prune_shared_indexers", True) | |
| and self.is_shared_layer | |
| ) | |
| if getattr(config, "use_index_share", False): | |
| from maxtext.utils import index_share_utils | |
| pattern = index_share_utils.parse_index_share_pattern(config.index_share_pattern, config.num_decoder_layers) | |
| self.is_full_array = jnp.array([p == "F" for p in pattern], dtype=jnp.bool_) | |
| self.served_group_size_array = jnp.array(index_share_utils.get_served_group_sizes(pattern), dtype=jnp.float32) | |
| if self.use_indexer and not is_pruned: |
| if layer_idx == 0: | ||
| num_f = pattern.count("F") | ||
| num_s = pattern.count("S") | ||
| absl.logging.info( | ||
| f"[GLM-5.2 IndexShare Active] Total layers: {config.num_decoder_layers} | " | ||
| f"Pattern: {config.index_share_pattern} | Full (F) layers with active indexers: {num_f} | " | ||
| f"Shared (S) layers with pruned indexers: {num_s} (Pruned {num_s / config.num_decoder_layers * 100:.1f}% indexer compute/parameters)" | ||
| ) |
There was a problem hiding this comment.
Logging on all processes in a multi-host environment can cause significant log spam. We should restrict this log message to process 0.
| if layer_idx == 0: | |
| num_f = pattern.count("F") | |
| num_s = pattern.count("S") | |
| absl.logging.info( | |
| f"[GLM-5.2 IndexShare Active] Total layers: {config.num_decoder_layers} | " | |
| f"Pattern: {config.index_share_pattern} | Full (F) layers with active indexers: {num_f} | " | |
| f"Shared (S) layers with pruned indexers: {num_s} (Pruned {num_s / config.num_decoder_layers * 100:.1f}% indexer compute/parameters)" | |
| ) | |
| if layer_idx == 0 and jax.process_index() == 0: | |
| num_f = pattern.count("F") | |
| num_s = pattern.count("S") | |
| absl.logging.info( | |
| f"[GLM-5.2 IndexShare Active] Total layers: {config.num_decoder_layers} | " | |
| f"Pattern: {config.index_share_pattern} | Full (F) layers with active indexers: {num_f} | " | |
| f"Shared (S) layers with pruned indexers: {num_s} (Pruned {num_s / config.num_decoder_layers * 100:.1f}% indexer compute/parameters)" | |
| ) |
3a132b5 to
e55e0a7
Compare
Description
This PR adds full end-to-end support for GLM-5.2 (744B Mixture of Experts) featuring Cross-Layer IndexShare for Dynamic Sparse Attention (DSA) across training, checkpoint conversion, and inference.
Background & Context
GLM-5.2 is a 744B MoE architecture with 78 layers ($3 \text{ dense} + 75 \text{ MoE}$ ), 256 routed experts + 1 shared expert (Top-8 routed tokens per token), and Multi-Head Latent Attention (MLA) augmented with a Dynamic Sparse Attention (DSA) Lightning Indexer.
In standard DSA implementations, every transformer layer independently computes indexer query/key projections (
wq_b,wk_b) and Top-k indexer scores. However, attention distributions exhibit strong cross-layer similarity across adjacent decoder stages. GLM-5.2 introduces Cross-Layer IndexShare, utilizing anFSSSperiodic pattern (1 Full layer followed by 3 Shared layers) where:Key Changes
src/maxtext/configs/models/glm5.2-744b.ymlwith native support foruse_index_share=true,index_share_pattern="FSSS", andprune_shared_indexers=true.src/maxtext/models/glm5.py(GLMDenseLayer,GLMMoELayer,GLMGenericLayer) to threadlayer_idxandcached_indexer_state._apply_layers_sequentiallyinsrc/maxtext/layers/nnx_decoders.pyto thread(carry_y, cached_indexer_state, layer_idx)throughjax.lax.scanwith invariant concrete tensor structures for HBM stability.src/maxtext/layers/attention_mla.pywithjax.lax.conddispatching to conditionally run full indexer calculations onzai-org/GLM-5.2safetensors to MaxText format into_maxtext.py, automatically pruning 74.4% of indexer weights on shared layers.tests/end_to_end/tpu/glm5/glm5.2-744b/1_test_glm5.sh(Checkpoint Conversion).tests/end_to_end/tpu/glm5/glm5.2-744b/2_test_glm5.sh(Pre-Training & Generation).Benefits
Tests
1. Checkpoint Conversion (Step 1)
2. Distributed Pre-Training & Verification on TPU v7x (32 Chips / 64 Devices)
python3 -m maxtext.trainers.pre_train.train src/maxtext/configs/base.yml \ base_output_directory="gs://maxtext-glm5-europe-west4/GLM-5.2/training_logs" \ run_name=glm52_pretrain_verify \ model_name=glm5.2-744b \ scan_layers=true \ use_indexer=true \ use_index_share=true \ index_share_pattern="FSSS" \ prune_shared_indexers=true \ indexer_sparse_training=true \ tokenizer_type=huggingface \ tokenizer_path=zai-org/GLM-5.2 \ dataset_type=synthetic \ per_device_batch_size=1 \ max_target_length=4096 \ ici_expert_parallelism=4 \ ici_fsdp_parallelism=16 \ steps=103. Autoregressive Generation & Decoding
python3 -m maxtext.inference.decode src/maxtext/configs/base.yml \ model_name=glm5.2-744b \ tokenizer_type=huggingface \ tokenizer_path=zai-org/GLM-5.2 \ load_parameters_path="gs://maxtext-glm5-europe-west4/maxtext-glm-5.2-bf16-converted-final-78l/0/items" \ scan_layers=true \ use_indexer=true \ use_index_share=true \ index_share_pattern="FSSS" \ prune_shared_indexers=true \ prompt="The capital of France is"Checklist
gemini-reviewlabel.