|
30 | 30 | from maxtext.utils import max_utils |
31 | 31 |
|
32 | 32 |
|
| 33 | +# Submodule names whose params are used by logits_from_hidden_states_for_vocab_tiling: |
| 34 | +# the final norm, the LM-head dense, and the embedding table when logits are tied. |
| 35 | +# vocab_tiling_nnx_loss splits these out as the only params the loss differentiates. |
| 36 | +_OUTPUT_HEAD_PATH_KEYS = ("token_embedder", "shared_embedding", "decoder_norm", "logits_dense") |
| 37 | + |
| 38 | + |
| 39 | +def _is_output_head_param_path(path, _value): |
| 40 | + """Filter for nnx.split: True when the param path belongs to the output head.""" |
| 41 | + |
| 42 | + # JAX path entries differ by key type: DictKey uses .key, GetAttrKey uses .name |
| 43 | + # in newer Flax and .attr in older. Check all three so the filter survives |
| 44 | + # version upgrades. |
| 45 | + def _name(k): |
| 46 | + for attr in ("key", "attr", "name"): |
| 47 | + v = getattr(k, attr, None) |
| 48 | + if v is not None: |
| 49 | + return str(v) |
| 50 | + return str(k) |
| 51 | + |
| 52 | + keys = [_name(k) for k in path] |
| 53 | + return any(k in keys for k in _OUTPUT_HEAD_PATH_KEYS) |
| 54 | + |
| 55 | + |
33 | 56 | def vocab_tiling_linen_loss( |
34 | 57 | hidden_states, |
35 | 58 | data, |
@@ -253,12 +276,12 @@ def _bwd_scan_body(grad_params_acc, chunk_data): |
253 | 276 | def vocab_tiling_nnx_loss(model, hidden_states, data, config, is_train): |
254 | 277 | """Computes cross-entropy loss with vocab tiling for NNX models. |
255 | 278 |
|
256 | | - NNX equivalent of ``vocab_tiling_linen_loss``. Scans the vocab dimension |
257 | | - and calls ``model.logits_from_hidden_states_for_vocab_tiling`` per chunk. The NNX model |
258 | | - carries its own parameters, so no explicit gather is needed. |
259 | | -
|
260 | | - Uses default autograd; a custom_vjp for backward memory savings can be |
261 | | - added later if needed. |
| 279 | + NNX equivalent of `vocab_tiling_linen_loss`. A `custom_vjp` runs the loss in |
| 280 | + vocab chunks via `jax.lax.scan` so the backward only holds one chunk's logits |
| 281 | + at a time, matching the Linen path's memory profile. `nnx.split` separates the |
| 282 | + output-head params (which the loss differentiates) from everything else; the |
| 283 | + rest of the model is passed through but not differentiated, so the scan's |
| 284 | + residuals stay small. |
262 | 285 |
|
263 | 286 | Args: |
264 | 287 | model: NNX model exposing ``logits_from_hidden_states_for_vocab_tiling``. |
@@ -320,42 +343,137 @@ def _reshape(inputs, out_shape, out_sharding): |
320 | 343 | labels = _maybe_shard_with_name(labels, label_spec) |
321 | 344 | segmentation = _maybe_shard_with_name(segmentation, label_spec) |
322 | 345 |
|
323 | | - batch_size, seq_len, emb_dim = hidden_states.shape |
324 | | - vocab_tile_size = (batch_size * seq_len) // config.num_vocab_tiling |
| 346 | + # head_params is what the loss differentiates; other_params (transformer layers) and |
| 347 | + # rest (rngs) are passed through the custom_vjp but not differentiated. They go through |
| 348 | + # as primals rather than closure captures: capturing them leaks tracers across the |
| 349 | + # custom_vjp + lax.scan boundary, which fails for tied embeddings. |
| 350 | + graphdef, head_params, other_params, rest = nnx.split(model, _is_output_head_param_path, nnx.Param, ...) |
325 | 351 |
|
326 | | - reshaped_hidden_states = _reshape( |
327 | | - hidden_states, (config.num_vocab_tiling, vocab_tile_size, emb_dim), reshaped_hidden_spec |
328 | | - ) |
329 | | - reshaped_labels = _reshape(labels, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec) |
330 | | - reshaped_segmentation = _reshape(segmentation, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec) |
331 | | - |
332 | | - # Rebuild the model per chunk inside the scan: the output head pulls an rng stream, and |
333 | | - # mutating the outer model's rng inside scan's sub-trace raises TraceContextError. |
334 | | - # nnx.merge(..., copy=True) makes fresh Variables local to each iteration. |
335 | | - graphdef, model_state = nnx.split(model) |
336 | | - |
337 | | - def _scan_body(accumulators, chunk_data): |
338 | | - loss_accumulator, z_loss_accumulator = accumulators |
339 | | - hidden_chunk, label_chunk, segmentation_chunk = chunk_data |
340 | | - hidden_chunk = _maybe_shard_with_name(hidden_chunk, chunked_hidden_spec) |
341 | | - label_chunk = _maybe_shard_with_name(label_chunk, chunked_data_spec) |
342 | | - segmentation_chunk = _maybe_shard_with_name(segmentation_chunk, chunked_data_spec) |
343 | | - |
344 | | - chunk_model = nnx.merge(graphdef, model_state, copy=True) |
345 | | - chunk_logits = chunk_model.logits_from_hidden_states_for_vocab_tiling(hidden_chunk, deterministic, model_mode) |
346 | | - chunk_logits = _maybe_shard_with_name(chunk_logits, chunked_logits_spec) |
347 | | - one_hot_label_chunk = jax.nn.one_hot(label_chunk, config.vocab_size) |
348 | | - chunk_xent, chunk_z_loss = max_utils.cross_entropy_with_logits( |
349 | | - chunk_logits, one_hot_label_chunk, z_loss=config.z_loss_multiplier |
| 352 | + def _logits_for_chunk(chunk_head_params, chunk_other_params, chunk_rest, hidden_chunk): |
| 353 | + local_model = nnx.merge(graphdef, chunk_head_params, chunk_other_params, chunk_rest, copy=True) |
| 354 | + chunk_logits = local_model.logits_from_hidden_states_for_vocab_tiling(hidden_chunk, deterministic, model_mode) |
| 355 | + return _maybe_shard_with_name(chunk_logits, chunked_logits_spec) |
| 356 | + |
| 357 | + @jax.custom_vjp |
| 358 | + def chunked_cross_entropy_loss(chunk_head_params, chunk_other_params, chunk_rest, hidden_states, labels, segmentation): |
| 359 | + (total_loss, total_z_loss), _ = _chunked_cross_entropy_loss_fwd( |
| 360 | + chunk_head_params, chunk_other_params, chunk_rest, hidden_states, labels, segmentation |
350 | 361 | ) |
| 362 | + return total_loss, total_z_loss |
| 363 | + |
| 364 | + def _chunked_cross_entropy_loss_fwd( |
| 365 | + chunk_head_params, chunk_other_params, chunk_rest, hidden_states, labels, segmentation |
| 366 | + ): |
| 367 | + batch_size, seq_len, emb_dim = hidden_states.shape |
| 368 | + vocab_tile_size = (batch_size * seq_len) // config.num_vocab_tiling |
| 369 | + |
| 370 | + reshaped_hidden_states = _reshape( |
| 371 | + hidden_states, (config.num_vocab_tiling, vocab_tile_size, emb_dim), reshaped_hidden_spec |
| 372 | + ) |
| 373 | + reshaped_labels = _reshape(labels, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec) |
| 374 | + reshaped_segmentation = _reshape(segmentation, (config.num_vocab_tiling, vocab_tile_size), reshaped_data_spec) |
| 375 | + |
| 376 | + def _fwd_scan_body(accumulators, chunk_data): |
| 377 | + loss_accumulator, z_loss_accumulator = accumulators |
| 378 | + hidden_chunk, label_chunk, segmentation_chunk = chunk_data |
| 379 | + hidden_chunk = _maybe_shard_with_name(hidden_chunk, chunked_hidden_spec) |
| 380 | + label_chunk = _maybe_shard_with_name(label_chunk, chunked_data_spec) |
| 381 | + segmentation_chunk = _maybe_shard_with_name(segmentation_chunk, chunked_data_spec) |
351 | 382 |
|
352 | | - masked_xent = jnp.sum(chunk_xent * (segmentation_chunk != 0)) |
353 | | - masked_z_loss = jnp.sum(chunk_z_loss * (segmentation_chunk != 0)) |
| 383 | + chunk_logits = _logits_for_chunk(chunk_head_params, chunk_other_params, chunk_rest, hidden_chunk) |
| 384 | + one_hot_label_chunk = jax.nn.one_hot(label_chunk, config.vocab_size) |
| 385 | + chunk_xent, chunk_z_loss = max_utils.cross_entropy_with_logits( |
| 386 | + chunk_logits, one_hot_label_chunk, z_loss=config.z_loss_multiplier |
| 387 | + ) |
354 | 388 |
|
355 | | - return (loss_accumulator + masked_xent, z_loss_accumulator + masked_z_loss), None |
| 389 | + masked_xent = jnp.sum(chunk_xent * (segmentation_chunk != 0)) |
| 390 | + masked_z_loss = jnp.sum(chunk_z_loss * (segmentation_chunk != 0)) |
| 391 | + |
| 392 | + return (loss_accumulator + masked_xent, z_loss_accumulator + masked_z_loss), None |
356 | 393 |
|
357 | | - initial_acc = (jnp.zeros((), dtype=hidden_states.dtype), jnp.zeros((), dtype=hidden_states.dtype)) |
358 | | - (total_loss, total_z_loss), _ = jax.lax.scan( |
359 | | - _scan_body, initial_acc, (reshaped_hidden_states, reshaped_labels, reshaped_segmentation) |
| 394 | + # Always accumulate in fp32 — `cross_entropy_with_logits` returns fp32 regardless of |
| 395 | + # logits dtype, and a bf16 carry would mismatch the body output type under lax.scan. |
| 396 | + initial_acc = (jnp.zeros((), dtype=jnp.float32), jnp.zeros((), dtype=jnp.float32)) |
| 397 | + (total_loss, total_z_loss), _ = jax.lax.scan( |
| 398 | + _fwd_scan_body, initial_acc, (reshaped_hidden_states, reshaped_labels, reshaped_segmentation) |
| 399 | + ) |
| 400 | + residuals = ( |
| 401 | + chunk_head_params, |
| 402 | + chunk_other_params, |
| 403 | + chunk_rest, |
| 404 | + reshaped_hidden_states, |
| 405 | + reshaped_labels, |
| 406 | + reshaped_segmentation, |
| 407 | + batch_size, |
| 408 | + seq_len, |
| 409 | + emb_dim, |
| 410 | + ) |
| 411 | + return (total_loss, total_z_loss), residuals |
| 412 | + |
| 413 | + def _chunked_cross_entropy_loss_bwd(residuals, cotangents): |
| 414 | + # z_loss is folded into the xent loss inside cross_entropy_with_logits. |
| 415 | + loss_cotangent, _ = cotangents |
| 416 | + |
| 417 | + ( |
| 418 | + chunk_head_params, |
| 419 | + chunk_other_params, |
| 420 | + chunk_rest, |
| 421 | + reshaped_hidden_states, |
| 422 | + reshaped_labels, |
| 423 | + reshaped_segmentation, |
| 424 | + batch_size, |
| 425 | + seq_len, |
| 426 | + emb_dim, |
| 427 | + ) = residuals |
| 428 | + |
| 429 | + def _single_chunk_loss_fn(input_head_params, input_hidden_chunk, input_label_chunk, input_segmentation_chunk): |
| 430 | + chunk_logits = _logits_for_chunk(input_head_params, chunk_other_params, chunk_rest, input_hidden_chunk) |
| 431 | + one_hot_label_chunk = jax.nn.one_hot(input_label_chunk, config.vocab_size) |
| 432 | + xent, _ = max_utils.cross_entropy_with_logits(chunk_logits, one_hot_label_chunk, z_loss=config.z_loss_multiplier) |
| 433 | + return jnp.sum(xent * (input_segmentation_chunk != 0)) |
| 434 | + |
| 435 | + def _bwd_scan_body(grad_head_acc, chunk_data): |
| 436 | + hidden_chunk, label_chunk, segmentation_chunk = chunk_data |
| 437 | + hidden_chunk = _maybe_shard_with_name(hidden_chunk, chunked_hidden_spec) |
| 438 | + label_chunk = _maybe_shard_with_name(label_chunk, chunked_data_spec) |
| 439 | + segmentation_chunk = _maybe_shard_with_name(segmentation_chunk, chunked_data_spec) |
| 440 | + |
| 441 | + # pylint: disable=unnecessary-lambda-assignment |
| 442 | + loss_fn_for_vjp = lambda p, h: _single_chunk_loss_fn(p, h, label_chunk, segmentation_chunk) |
| 443 | + _, vjp_fn = jax.vjp(loss_fn_for_vjp, chunk_head_params, hidden_chunk) |
| 444 | + (grad_head_update, grad_hidden_chunk) = vjp_fn(1.0) |
| 445 | + grad_hidden_chunk = _maybe_shard_with_name(grad_hidden_chunk, chunked_hidden_spec) |
| 446 | + |
| 447 | + grad_head_acc = jax.tree_util.tree_map(lambda acc, update: acc + update, grad_head_acc, grad_head_update) |
| 448 | + return grad_head_acc, grad_hidden_chunk |
| 449 | + |
| 450 | + initial_grad_head = jax.tree_util.tree_map(jnp.zeros_like, chunk_head_params) |
| 451 | + |
| 452 | + grad_head, grad_reshaped_hidden_states = jax.lax.scan( |
| 453 | + _bwd_scan_body, initial_grad_head, (reshaped_hidden_states, reshaped_labels, reshaped_segmentation) |
| 454 | + ) |
| 455 | + grad_reshaped_hidden_states = _maybe_shard_with_name(grad_reshaped_hidden_states, reshaped_hidden_spec) |
| 456 | + grad_head = jax.tree_util.tree_map(lambda g: g * loss_cotangent, grad_head) |
| 457 | + grad_head = jax.tree_util.tree_map(lambda x, y: y.astype(x.dtype), chunk_head_params, grad_head) |
| 458 | + grad_reshaped_hidden_states = _reshape(grad_reshaped_hidden_states, (batch_size, seq_len, emb_dim), hidden_spec) |
| 459 | + |
| 460 | + # Return explicit zeros for other_params and rest, not None. With None, JAX builds |
| 461 | + # the zero cotangents with the wrong layer-axis order for scanned params, and the |
| 462 | + # AOT trace fails the cotangent shape check. |
| 463 | + grad_other = jax.tree_util.tree_map(jnp.zeros_like, chunk_other_params) |
| 464 | + grad_rest = jax.tree_util.tree_map(jnp.zeros_like, chunk_rest) |
| 465 | + return ( |
| 466 | + grad_head, |
| 467 | + grad_other, |
| 468 | + grad_rest, |
| 469 | + grad_reshaped_hidden_states.astype(reshaped_hidden_states.dtype), |
| 470 | + None, |
| 471 | + None, |
| 472 | + ) |
| 473 | + |
| 474 | + chunked_cross_entropy_loss.defvjp(_chunked_cross_entropy_loss_fwd, _chunked_cross_entropy_loss_bwd) |
| 475 | + |
| 476 | + total_loss, total_z_loss = chunked_cross_entropy_loss( |
| 477 | + head_params, other_params, rest, hidden_states, labels, segmentation |
360 | 478 | ) |
361 | 479 | return total_loss, total_z_loss |
0 commit comments