someone-in-the-world Claude Sonnet 5 commited on
Commit
c0f3a61
·
1 Parent(s): 8db56cb

Patch RMSNorm for fp8-resident weights; fix crash in prior fp8 commit

Browse files

8db56cb kept the transformer's weights fp8-resident (instead of upcasting
to bf16 at load) to fix the memory OOM that was forcing this Space onto
the slow enable_model_cpu_offload path. It patched nn.Linear to upcast
its weight just-in-time for the matmul, but this model also uses RMSNorm
extensively (attn.norm_q/norm_k/norm_added_q/norm_added_k in every
transformer block, plus self.txt_norm) with real learnable weights —
confirmed via the checkpoint's safetensors headers, which show 100% of
tensors across all 3 shards as F8_E4M3, including norm gains and biases,
no mixed precision.

diffusers 0.39.0's RMSNorm.forward only upcasts its weight for
float16/bfloat16, not float8. Left unpatched, `hidden_states * self.weight`
multiplies a bf16 activation by an fp8 weight directly, which PyTorch
does not support via type promotion. Verified this reproduces exactly:

RuntimeError: Promotion for Float8 Types is not supported, attempted
to promote Float and Float8_e4m3fn

This adds _fp8_upcast_rmsnorm_forward, a copy of diffusers' RMSNorm.forward
with the same just-in-time upcast idea applied to RMSNorm's weight/bias,
and renames _patch_fp8_linears to _patch_fp8_modules to patch both module
types. Also adds a safety-net scan that logs a warning for any other
fp8-resident parameter neither patcher covers, so a future gap in this
allowlist surfaces at startup instead of mid-inference.

Verified locally (CPU, diffusers 0.39.0 installed in .venv): the
unpatched RMSNorm reproduces the RuntimeError above on an fp8 weight;
the patched version runs and matches a real-bf16-weight reference within
~0.003 max abs diff, consistent with fp8's own quantization noise rather
than a logic error. Still needs a real end-to-end run on the Space's GPU
before merging — this only verifies the patch in isolation, not the full
pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +53 -8
app.py CHANGED
@@ -74,6 +74,7 @@ print("[startup] importing dimensions...", flush=True)
74
  from dimensions import compute_output_dimensions, max_dim_for_mode
75
  print("[startup] importing diffusers...", flush=True)
76
  from diffusers import FlowMatchEulerDiscreteScheduler
 
77
  print("[startup] importing QwenImageEditPlusPipeline...", flush=True)
78
  from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
79
  print("[startup] importing QwenImageTransformer2DModel...", flush=True)
@@ -104,18 +105,62 @@ def _fp8_upcast_linear_forward(self, input):
104
  return torch.nn.functional.linear(input, weight, bias)
105
 
106
 
107
- def _patch_fp8_linears(model) -> int:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  # This checkpoint ships its weights natively in fp8 (torch_dtype below preserves that
109
  # instead of upcasting to bf16 at load time, halving resident memory: ~19GB vs ~38GB).
110
- # nn.Linear has no fp8 compute kernel on this GPU, so each patched layer upcasts its own
111
- # weight to the input's dtype just-in-time for the matmul mathematically identical to
112
- # the old load-time-upcast-everything approach (same values, same target dtype), just
113
- # deferred so only one layer's weight is transiently bf16 at a time instead of all of them.
 
 
114
  count = 0
115
  for module in model.modules():
116
  if isinstance(module, torch.nn.Linear) and module.weight.dtype in _FP8_DTYPES:
117
  module.forward = types.MethodType(_fp8_upcast_linear_forward, module)
118
  count += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  return count
120
 
121
 
@@ -129,8 +174,8 @@ _transformer = QwenImageTransformer2DModel.from_pretrained(
129
  )
130
  _hb.set()
131
  print(f"[startup] transformer loaded in {time.perf_counter()-_t0_load:.1f}s", flush=True)
132
- _n_fp8_patched = _patch_fp8_linears(_transformer)
133
- print(f"[startup] patched {_n_fp8_patched} fp8-resident nn.Linear modules for just-in-time upcast", flush=True)
134
  try:
135
  print(f"[startup] transformer memory footprint: {_transformer.get_memory_footprint()/1024**3:.2f}GB", flush=True)
136
  except Exception as e:
@@ -439,7 +484,7 @@ def _log_gpu_properties(cuda_ok):
439
  # pipeline (~40GB transformer + ~14GB text encoder) peaked at 46.82GB moving
440
  # onto this Space's 47GB 2g.48gb MIG slice and still OOM'd, wasting ~40s
441
  # before falling back. The transformer now stays fp8-resident (see
442
- # _patch_fp8_linears above, ~19GB instead of ~38GB), so the full pipeline
443
  # should total roughly ~35GB — comfortably under the slice with headroom to
444
  # spare. Lowered accordingly, but the OOM fallback below stays as a safety
445
  # net in case that estimate is off.
 
74
  from dimensions import compute_output_dimensions, max_dim_for_mode
75
  print("[startup] importing diffusers...", flush=True)
76
  from diffusers import FlowMatchEulerDiscreteScheduler
77
+ from diffusers.models.normalization import RMSNorm
78
  print("[startup] importing QwenImageEditPlusPipeline...", flush=True)
79
  from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline
80
  print("[startup] importing QwenImageTransformer2DModel...", flush=True)
 
105
  return torch.nn.functional.linear(input, weight, bias)
106
 
107
 
108
+ def _fp8_upcast_rmsnorm_forward(self, hidden_states):
109
+ # Mirrors diffusers 0.39.0's RMSNorm.forward (CUDA path, models/normalization.py), extended
110
+ # so an fp8-resident weight/bias gets upcast to the activation's dtype before use instead of
111
+ # being silently skipped — the stock implementation only special-cases float16/bfloat16, so
112
+ # an fp8 weight would otherwise reach `hidden_states * self.weight` unconverted and error
113
+ # (no elementwise op supports bf16 x fp8 operands).
114
+ input_dtype = hidden_states.dtype
115
+ variance = hidden_states.to(torch.float32).pow(2).mean(-1, keepdim=True)
116
+ hidden_states = hidden_states * torch.rsqrt(variance + self.eps)
117
+
118
+ if self.weight is not None:
119
+ weight = self.weight.to(input_dtype) if self.weight.dtype in _FP8_DTYPES else self.weight
120
+ if weight.dtype in (torch.float16, torch.bfloat16):
121
+ hidden_states = hidden_states.to(weight.dtype)
122
+ hidden_states = hidden_states * weight
123
+ if self.bias is not None:
124
+ bias = self.bias.to(hidden_states.dtype) if self.bias.dtype in _FP8_DTYPES else self.bias
125
+ hidden_states = hidden_states + bias
126
+ else:
127
+ hidden_states = hidden_states.to(input_dtype)
128
+
129
+ return hidden_states
130
+
131
+
132
+ def _patch_fp8_modules(model) -> int:
133
  # This checkpoint ships its weights natively in fp8 (torch_dtype below preserves that
134
  # instead of upcasting to bf16 at load time, halving resident memory: ~19GB vs ~38GB).
135
+ # Neither nn.Linear nor RMSNorm (the two module types in this model that own their own
136
+ # weight/bias, per the checkpoint's safetensors headers every tensor is fp8, including
137
+ # norm gains) have an fp8 compute kernel on this GPU, so each patched instance upcasts its
138
+ # own weight to the input's dtype just-in-time for the op mathematically identical to the
139
+ # old load-time-upcast-everything approach (same values, same target dtype), just deferred
140
+ # so only one layer's weight is transiently bf16 at a time instead of all of them.
141
  count = 0
142
  for module in model.modules():
143
  if isinstance(module, torch.nn.Linear) and module.weight.dtype in _FP8_DTYPES:
144
  module.forward = types.MethodType(_fp8_upcast_linear_forward, module)
145
  count += 1
146
+ elif isinstance(module, RMSNorm) and module.weight is not None and module.weight.dtype in _FP8_DTYPES:
147
+ module.forward = types.MethodType(_fp8_upcast_rmsnorm_forward, module)
148
+ count += 1
149
+
150
+ # Safety net: flag any other fp8-resident parameter that wasn't patched above, so a gap in
151
+ # this allowlist surfaces as a startup log line instead of a mid-inference crash — an
152
+ # unpatched fp8 parameter can't participate in ops with the bf16 activations around it.
153
+ patched_types = (torch.nn.Linear, RMSNorm)
154
+ for name, module in model.named_modules():
155
+ if isinstance(module, patched_types):
156
+ continue
157
+ for pname, param in module.named_parameters(recurse=False):
158
+ if param.dtype in _FP8_DTYPES:
159
+ print(
160
+ f"[startup] WARNING: unpatched fp8 parameter {name}.{pname} "
161
+ f"({type(module).__name__}) — will likely error at inference",
162
+ flush=True,
163
+ )
164
  return count
165
 
166
 
 
174
  )
175
  _hb.set()
176
  print(f"[startup] transformer loaded in {time.perf_counter()-_t0_load:.1f}s", flush=True)
177
+ _n_fp8_patched = _patch_fp8_modules(_transformer)
178
+ print(f"[startup] patched {_n_fp8_patched} fp8-resident nn.Linear/RMSNorm modules for just-in-time upcast", flush=True)
179
  try:
180
  print(f"[startup] transformer memory footprint: {_transformer.get_memory_footprint()/1024**3:.2f}GB", flush=True)
181
  except Exception as e:
 
484
  # pipeline (~40GB transformer + ~14GB text encoder) peaked at 46.82GB moving
485
  # onto this Space's 47GB 2g.48gb MIG slice and still OOM'd, wasting ~40s
486
  # before falling back. The transformer now stays fp8-resident (see
487
+ # _patch_fp8_modules above, ~19GB instead of ~38GB), so the full pipeline
488
  # should total roughly ~35GB — comfortably under the slice with headroom to
489
  # spare. Lowered accordingly, but the OOM fallback below stays as a safety
490
  # net in case that estimate is off.