Skip to content

Commit 3e1a5ba

Browse files
le1nuxclaude
andcommitted
fix(model): initialize wrapped models, bound trunc_normal_, keep all pipeline stages
Three pre-existing bugs, each producing a silently wrong model rather than an error. 1. Weight initialization was skipped entirely for wrapped models. NamedParameterwiseNormalInitialization and Llama3Initializer stripped only torch.compile's `_orig_mod.` prefix before matching their parameter-name regexes. Activation checkpointing and FSDP1 insert their own segments (`_checkpoint_wrapped_module.`, `_fsdp_wrapped_module.`), so any config that wrapped the model before initializing it matched *no* per-layer regex and silently kept the default initialization. A shared normalize_parameter_name now strips all of them. 2. Llama3Initializer injected one out-of-distribution weight into ~0.3% of tensors. Its trunc_normal_ calls passed a=-2, b=2; torch treats those as absolute bounds, but the intended standard deviations are 0.02 and smaller, so the bounds sat at +-100 to +-283 sigma. That is not just a no-op: the erf limits of the inverse-transform sampler saturate, its singular edge becomes reachable, and the final clamp pins the affected element to exactly the bound - one weight of magnitude 2.0 in a tensor whose intended scale is 0.007. Measured 12/4000 tensors (0.30%); after the fix 0/4000. Bounds are now expressed in standard deviations, matching both the convention this same file already used for the output projection (3 / sqrt(n_embd), i.e. 3 * std) and the llama3 reference implementation. This is the root cause of the intermittent TestLlama3LikeInitialization failures: the pinned element dominates the sample variance, which is what the test's std assertions detect. Measured 6 failures in 40 runs (15%) before, 0 in 60 after. The test's max/min assertions were pinned to the old absolute bound; they now assert 3 * std, so they detect a stray element rather than tolerating one. 3. Pipeline stage generation silently dropped modules. get_stages packed split points greedily against a fixed per-stage weight cap while looping exactly num_virtual_stages times; whatever remained when the packing did not fit was never assigned - in practice the output split point, yielding a pipeline with no lm_head on any stage. Uniform per-layer weights always happen to fit, which is why GPT2 never hit it (verified: 0 of ~1000 GPT2 configurations drop anything), but any generator with non-uniform per-layer cost does. Packing now uses a partitioner that provably assigns every module exactly once, and requesting more stages than split points raises instead of returning empty stages. Adds tests/models/parallelism/test_stages_generator.py and tests/nn/model_initialization/test_fqn_normalization.py. Five of the stage tests fail against the old packer, including one asserting lm_head survives; GPT2's cases pass either way, confirming GPT2 was unaffected. Note: item 2 changes the numerics of Llama3Initializer. Seeded runs from before and after will not produce identical weights. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eadfd44 commit 3e1a5ba

8 files changed

Lines changed: 419 additions & 59 deletions

File tree

CHANGELOG_DEV.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
|------------------|------------|---------------|------------------|------------------------------------------------------------------------------------------------|
55
| [#141](#pr-141-towards-stable-modalities-version) | Bug Fix | [#129](https://github.com/Modalities/modalities/issues/129) | **Yes** | Towards stable modalities version |
66
| [#154](pr-154-manual-swiglu-implementation) | Bug Fix | [#14](https://github.com/Modalities/modalities/issues/14) | **Yes** | Towards stable modalities version |
7+
| [#init-and-pipeline-fixes](#pr-initialization-and-pipeline-stage-fixes) | Bug Fix | -- | No | Three silent-correctness fixes: initialization behind wrappers, trunc_normal_ bounds, pipeline stage coverage |
78
| | | | | |
89

910

@@ -217,4 +218,48 @@ This PR improves training monitoring and logging across runs besides some other
217218
* Add tutorials on Einsum Transformer (Example model integration) and profiling
218219

219220
**Breaking Changes**
220-
* experiments_root_path is now exposed on an API level
221+
* experiments_root_path is now exposed on an API level
222+
223+
## PR Initialization and pipeline stage fixes
224+
225+
Three pre-existing bugs, each of which produced a silently wrong model rather than an error.
226+
227+
**1. Weight initialization was skipped entirely for wrapped models.**
228+
`NamedParameterwiseNormalInitialization` and `Llama3Initializer` stripped only torch.compile's
229+
`_orig_mod.` prefix from parameter names before matching their regexes. Activation checkpointing and
230+
FSDP1 insert their own segments (`_checkpoint_wrapped_module.`, `_fsdp_wrapped_module.`), so any
231+
config that wrapped the model before initializing it matched *no* per-layer regex and kept the
232+
default initialization. All wrapper prefixes are now normalized away by a shared
233+
`normalize_parameter_name`.
234+
235+
**2. `Llama3Initializer` injected a single out-of-distribution weight into ~0.3% of tensors.**
236+
Its `trunc_normal_` calls passed `a=-2, b=2`. torch treats those as *absolute* bounds, but the
237+
intended standard deviations are 0.02 and smaller, so the bounds sat at +-100 to +-283 sigma. That is
238+
not merely a no-op: the erf limits of the inverse-transform sampler saturate, its singular edge
239+
becomes reachable, and the final clamp pins the affected element to exactly the bound - putting one
240+
weight of magnitude 2.0 into a tensor whose intended scale is 0.007. Measured rate: 12 of 4000
241+
tensors (0.30%). Bounds are now expressed in standard deviations (`_TRUNCATION_IN_STDS = 3.0`),
242+
matching the convention the same file already used for the output projection and the one used by the
243+
llama3 reference implementation. After the fix: 0 of 4000.
244+
245+
This is also the root cause of the intermittent `TestLlama3LikeInitialization` failures. The single
246+
pinned element dominates the sample variance, which is exactly what the test's `std` assertions
247+
detect. Measured before the fix: 6 failures in 40 runs (15%); after: 0 in 60. The test's `max`/`min`
248+
assertions were loosened to the old absolute bound and are now tightened to `3 * std`, so they
249+
actually detect a stray element instead of tolerating one.
250+
251+
**3. Pipeline stage generation silently dropped modules.**
252+
`StagesGenerator.get_stages` packed split points greedily against a fixed per-stage weight cap while
253+
looping exactly `num_virtual_stages` times. Whatever remained when the packing did not fit was never
254+
assigned to any stage - in practice the output split point, producing a pipeline with no `lm_head` on
255+
any stage. Uniform per-layer weights always happen to fit, which is why GPT2 never triggered it
256+
(verified: 0 of ~1000 GPT2 configurations drop anything); any generator with non-uniform per-layer
257+
cost hits it immediately. Stage packing now uses a partitioner that provably assigns every module to
258+
exactly one stage, and asking for more stages than split points raises instead of returning empty
259+
stages.
260+
261+
**Breaking changes:**
262+
* None in API terms, but item 2 changes the *numerics* of `Llama3Initializer`: truncation now happens
263+
at 3 standard deviations instead of an effectively unbounded absolute value. Runs seeded before and
264+
after this change will not produce identical weights. This is a fix to the intended behaviour, not
265+
a re-tuning.

src/modalities/models/gpt2/llama3_like_initialization.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,17 @@
88

99
from modalities.models.gpt2.gpt2_model import GPT2LLM
1010
from modalities.nn.model_initialization.initialization_if import ModelInitializationIF
11+
from modalities.nn.model_initialization.initialization_routines import normalize_parameter_name
1112
from modalities.utils.logger_utils import get_logger
1213

14+
# Truncation bounds for trunc_normal_, expressed in standard deviations. torch's trunc_normal_
15+
# takes `a`/`b` as *absolute* values, so they must be scaled by std. Using an absolute bound that
16+
# is far outside the distribution (e.g. +-2 with std=0.007, i.e. +-283 sigma) is not merely a no-op:
17+
# the erf limits of the inverse-transform sampler saturate, its singular edge is reachable, and the
18+
# final clamp pins the affected element to exactly the bound. That injected one weight of magnitude
19+
# 2.0 into roughly 0.3% of tensors - a 283 sigma outlier that dominated the tensor's variance.
20+
_TRUNCATION_IN_STDS = 3.0
21+
1322
logger = get_logger(name="llama3 initialization")
1423

1524

@@ -47,8 +56,6 @@ def _build_regex_to_init(self, use_weight_tying: bool) -> dict[str, tuple[Callab
4756
{
4857
"mean": 0.0,
4958
"std": 0.02,
50-
"a": -2,
51-
"b": 2,
5259
},
5360
),
5461
# final attention projection in attention block
@@ -61,8 +68,6 @@ def _build_regex_to_init(self, use_weight_tying: bool) -> dict[str, tuple[Callab
6168
if self.depth_init
6269
else 0.02 / math.sqrt(2 * self.num_layers)
6370
),
64-
"a": -2,
65-
"b": 2,
6671
},
6772
),
6873
# SwiGLU
@@ -71,8 +76,6 @@ def _build_regex_to_init(self, use_weight_tying: bool) -> dict[str, tuple[Callab
7176
{
7277
"mean": 0.0,
7378
"std": 0.02,
74-
"a": -2,
75-
"b": 2,
7679
},
7780
),
7881
r"transformer\.h\.\d+\.mlp\.(V|W_2)\.weight": (
@@ -84,8 +87,6 @@ def _build_regex_to_init(self, use_weight_tying: bool) -> dict[str, tuple[Callab
8487
if self.depth_init
8588
else 0.02 / math.sqrt(2 * self.num_layers)
8689
),
87-
"a": -2,
88-
"b": 2,
8990
},
9091
),
9192
}
@@ -136,10 +137,11 @@ def _init_by_fqn_regex(model: nn.Module, regex_to_init: dict[str, tuple[Callable
136137
f"Bias initialization is not allowed for Llama3Initializer. Found bias parameter: {parameter_name}"
137138
)
138139
match_count = 0
140+
# Strip FQN modifications introduced by torch.compile, activation checkpointing and
141+
# FSDP1 so that the regexes can be written against the plain model. Done once, before
142+
# the loop, rather than repeatedly inside it.
143+
parameter_name = normalize_parameter_name(parameter_name)
139144
for weight_regex in regex_to_init.keys():
140-
parameter_name = parameter_name.replace(
141-
"_orig_mod.", ""
142-
) # remove FQN modification from torch.compile if present
143145
if re.fullmatch(weight_regex, parameter_name):
144146
init_fn, arg_dict = regex_to_init[weight_regex]
145147
if arg_dict["std"] is not None and callable(arg_dict["std"]):
@@ -154,6 +156,11 @@ def _init_by_fqn_regex(model: nn.Module, regex_to_init: dict[str, tuple[Callable
154156
f"Could not extract layer_id from parameter name {parameter_name} "
155157
"for dynamic std calculation"
156158
)
159+
if init_fn is trunc_normal_ and "a" not in arg_dict:
160+
# Bounds are expressed relative to std; see _TRUNCATION_IN_STDS.
161+
arg_dict = arg_dict.copy()
162+
arg_dict["a"] = -_TRUNCATION_IN_STDS * arg_dict["std"]
163+
arg_dict["b"] = _TRUNCATION_IN_STDS * arg_dict["std"]
157164
init_fn(p, **arg_dict)
158165
match_count += 1
159166
hits[weight_regex] += 1

src/modalities/models/parallelism/stages_generator.py

Lines changed: 116 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -41,29 +41,22 @@ def get_stages(self, num_layers_per_stage: int, pp_dims: int) -> list[list[str]]
4141
# The computational weight of the input and output modules are estimated
4242
# based on the number of layers they correspond to.
4343
potential_split_points = self._get_potential_split_points()
44-
# Calculate the weight per stage based on the total weight and number of stages
45-
weight_per_stage = math.ceil(sum(weight for _, weight in potential_split_points) / num_virtual_stages)
46-
# pack the stages with the layers
47-
next_split_point = 0
48-
module_names_per_stage: list[list[str]] = []
49-
for _ in range(num_virtual_stages):
50-
stage_fqns = []
51-
stage_weight = 0
52-
while next_split_point < len(potential_split_points):
53-
fqns, weight = potential_split_points[next_split_point]
54-
if weight > weight_per_stage:
55-
raise ValueError(
56-
f"Weight of {weight} for {fqns} exceeds weight per stage {weight_per_stage}. "
57-
"Please adjust the number of stages or the weight distribution."
58-
)
59-
if stage_weight + weight > weight_per_stage:
60-
break
61-
stage_fqns.extend(fqns)
62-
stage_weight += weight
63-
next_split_point += 1
64-
module_names_per_stage.append(stage_fqns)
65-
66-
return module_names_per_stage
44+
if num_virtual_stages > len(potential_split_points):
45+
raise ValueError(
46+
f"Cannot build {num_virtual_stages} pipeline stages from only "
47+
f"{len(potential_split_points)} split points. Increase num_layers_per_stage or "
48+
f"reduce the pipeline degree."
49+
)
50+
# Pack the split points into contiguous stages, balancing computational weight.
51+
#
52+
# This used to pack greedily against a fixed per-stage weight cap, looping exactly
53+
# num_virtual_stages times. When the packing did not happen to fit, whatever was left over
54+
# was never assigned to any stage and was silently discarded - typically the output split
55+
# point, producing a pipeline with no lm_head on any stage. Uniform per-layer weights (as
56+
# in GPT2) always happen to fit, which is why this went unnoticed; any generator with
57+
# non-uniform weights hits it.
58+
groups = _partition_contiguous(potential_split_points, num_parts=num_virtual_stages)
59+
return [[fqn for fqns, _ in group for fqn in fqns] for group in groups]
6760

6861
@abstractmethod
6962
def _get_potential_split_points(self) -> list[tuple[list[str], int]]:
@@ -114,3 +107,103 @@ def _get_potential_split_points(
114107
]
115108

116109
return potential_split_points
110+
111+
112+
def _greedy_pack(split_points: list[tuple[list[str], int]], weight_cap: int) -> list[list[tuple[list[str], int]]]:
113+
"""
114+
Packs split points left to right into contiguous groups, each at most ``weight_cap`` heavy.
115+
116+
Unlike a fixed-stage-count loop, this consumes every split point: a group is closed and a new
117+
one started whenever the cap would be exceeded.
118+
119+
Args:
120+
split_points (list[tuple[list[str], int]]): The split points with their weights, in order.
121+
weight_cap (int): Maximum weight per group. Must be at least the heaviest split point.
122+
123+
Returns:
124+
list[list[tuple[list[str], int]]]: The resulting groups, covering every split point.
125+
"""
126+
groups: list[list[tuple[list[str], int]]] = []
127+
current: list[tuple[list[str], int]] = []
128+
current_weight = 0
129+
for split_point in split_points:
130+
weight = split_point[1]
131+
if current and current_weight + weight > weight_cap:
132+
groups.append(current)
133+
current, current_weight = [], 0
134+
current.append(split_point)
135+
current_weight += weight
136+
if current:
137+
groups.append(current)
138+
return groups
139+
140+
141+
def _best_binary_split(group: list[tuple[list[str], int]]) -> int:
142+
"""
143+
Finds the index at which splitting a group minimizes the weight of its heavier half.
144+
145+
Args:
146+
group (list[tuple[list[str], int]]): The group to split, with at least two entries.
147+
148+
Returns:
149+
int: The split index, in ``[1, len(group) - 1]``.
150+
"""
151+
weights = [weight for _, weight in group]
152+
total = sum(weights)
153+
best_index, best_cost = 1, None
154+
prefix = 0
155+
for index in range(1, len(group)):
156+
prefix += weights[index - 1]
157+
cost = max(prefix, total - prefix)
158+
if best_cost is None or cost < best_cost:
159+
best_index, best_cost = index, cost
160+
return best_index
161+
162+
163+
def _partition_contiguous(
164+
split_points: list[tuple[list[str], int]], num_parts: int
165+
) -> list[list[tuple[list[str], int]]]:
166+
"""
167+
Partitions split points into exactly ``num_parts`` contiguous, non-empty, balanced groups.
168+
169+
Finds the smallest per-group weight cap for which a left-to-right greedy pass fits within
170+
``num_parts`` groups (binary search over the cap), then splits the heaviest splittable group
171+
until the requested count is reached. Every split point is assigned exactly once.
172+
173+
Args:
174+
split_points (list[tuple[list[str], int]]): The split points with their weights, in order.
175+
num_parts (int): The exact number of groups to produce.
176+
177+
Raises:
178+
ValueError: If there are fewer split points than requested groups.
179+
180+
Returns:
181+
list[list[tuple[list[str], int]]]: The groups, covering every split point exactly once.
182+
"""
183+
if num_parts > len(split_points):
184+
raise ValueError(f"Cannot partition {len(split_points)} split points into {num_parts} groups.")
185+
if num_parts == 1:
186+
return [list(split_points)]
187+
188+
weights = [weight for _, weight in split_points]
189+
low, high = max(weights), sum(weights)
190+
feasible_cap = high
191+
while low <= high:
192+
candidate = (low + high) // 2
193+
if len(_greedy_pack(split_points, weight_cap=candidate)) <= num_parts:
194+
feasible_cap = candidate
195+
high = candidate - 1
196+
else:
197+
low = candidate + 1
198+
199+
groups = _greedy_pack(split_points, weight_cap=feasible_cap)
200+
# The binary search only guarantees "at most num_parts" groups. Split the heaviest splittable
201+
# group until the requested count is reached; pipeline parallelism needs exactly this many.
202+
while len(groups) < num_parts:
203+
splittable = [index for index, group in enumerate(groups) if len(group) > 1]
204+
heaviest = max(splittable, key=lambda index: sum(weight for _, weight in groups[index]))
205+
group = groups.pop(heaviest)
206+
split_index = _best_binary_split(group)
207+
groups.insert(heaviest, group[split_index:])
208+
groups.insert(heaviest, group[:split_index])
209+
return groups

src/modalities/nn/model_initialization/initialization_routines.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,34 @@ class MultiDeviceGeneratorPolicy(str, Enum):
1818
ERROR = "error"
1919

2020

21+
# Wrappers that insert themselves into a parameter's fully qualified name without changing which
22+
# logical parameter it is. The initialization filters are written against the plain model FQNs, so
23+
# these prefixes are stripped before matching. Without this, applying activation checkpointing (or
24+
# FSDP1) before initialization would silently prevent every per-layer regex from matching, leaving
25+
# the model with its default rather than its configured initialization.
26+
_FQN_WRAPPER_PREFIXES = (
27+
"_orig_mod.", # torch.compile
28+
"_checkpoint_wrapped_module.", # activation checkpointing
29+
"_fsdp_wrapped_module.", # FSDP1
30+
)
31+
32+
33+
def normalize_parameter_name(parameter_name: str) -> str:
34+
"""
35+
Removes wrapper prefixes from a parameter's fully qualified name.
36+
37+
Args:
38+
parameter_name (str): The fully qualified parameter name, possibly containing wrapper
39+
segments such as ``_checkpoint_wrapped_module.``.
40+
41+
Returns:
42+
str: The name as it would appear on the unwrapped model.
43+
"""
44+
for prefix in _FQN_WRAPPER_PREFIXES:
45+
parameter_name = parameter_name.replace(prefix, "")
46+
return parameter_name
47+
48+
2149
class PlainInitializationConfig(BaseModel):
2250
mean: float
2351
std: Annotated[float, Field(strict=True, ge=0.0)] | str # can be float or "auto"
@@ -85,9 +113,9 @@ def initialize_in_place(self, model: nn.Module):
85113
weight_regexes = self.parameter_name_regexes.weights
86114
bias_regexes = self.parameter_name_regexes.biases or []
87115
for parameter_name, p in model.named_parameters():
88-
parameter_name = parameter_name.replace(
89-
"_orig_mod.", ""
90-
) # remove FQN modification from torch.compile if present
116+
# Strip FQN modifications introduced by torch.compile, activation checkpointing and
117+
# FSDP1 so that the filters can be written against the plain model.
118+
parameter_name = normalize_parameter_name(parameter_name)
91119
for weight_regex in weight_regexes:
92120
if re.fullmatch(weight_regex, parameter_name):
93121
nn.init.normal_(p, mean=self.mean, std=self.std, generator=self._get_generator(p))

tests/models/parallelism/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)