Skip to content

Commit fede96c

Browse files
feat: add --precision fp16 to optimize, build, and export commands
Add FP16 precision conversion support across all model pipeline commands: - Create optim/fp16.py with convert_to_fp16() utility (wraps ORT float16) - optimize: --precision fp16 with --fp16-keep-io-types and --fp16-op-block-list - build: --precision fp16 stage between optimize and quantize - export: --precision fp16 as post-export conversion - Add shared precision_option() CLI decorator in utils/cli.py Design: FP16 is a precision transformation (not a graph optimization), so it lives as a command-layer utility rather than an optimizer pipe. All three commands share the same convert_to_fp16() function. Fixes #867
1 parent 507c269 commit fede96c

8 files changed

Lines changed: 412 additions & 15 deletions

File tree

src/winml/modelkit/commands/build.py

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,9 @@ def _validate_loader_tasks_for_model(
494494
help="Maximum autoconf re-optimization rounds (default: 3). --no-analyze sets this to 0.",
495495
)
496496
@cli_utils.allow_unsupported_nodes_option()
497+
@cli_utils.precision_option(
498+
optional_message="When fp16, applies FP16 conversion during optimization."
499+
)
497500
@cli_utils.trust_remote_code_option(
498501
optional_message="Trust remote code for custom model architectures (e.g., Mu2)."
499502
)
@@ -514,6 +517,7 @@ def build(
514517
analyze: bool,
515518
max_optim_iterations: int | None,
516519
allow_unsupported_nodes: bool,
520+
precision: str | None,
517521
trust_remote_code: bool,
518522
verbose: int,
519523
quiet: bool,
@@ -674,6 +678,8 @@ def _patch_device(cfg: WinMLBuildConfig) -> None:
674678
# on the key being present, matching the module-mode path which passes
675679
# allow_unsupported_nodes explicitly regardless of its value.
676680
extra_kwargs["allow_unsupported_nodes"] = allow_unsupported_nodes
681+
if precision == "fp16":
682+
extra_kwargs["precision"] = "fp16"
677683

678684
if isinstance(config_or_configs, list):
679685
# ---- MODULE MODE: array config, one build per submodule ----
@@ -1119,6 +1125,45 @@ def _on_reoptimize(autoconf_dict: dict) -> None:
11191125
return current_path, opt_elapsed
11201126

11211127

1128+
def _run_fp16_stage(
1129+
*,
1130+
model_path: Path,
1131+
stage_timings: list[tuple[str, float | None]],
1132+
) -> Path:
1133+
"""Run FP16 conversion stage on an ONNX model file.
1134+
1135+
Loads the model, applies FP16 conversion with keep_io_types=True,
1136+
and overwrites the file in-place.
1137+
1138+
Args:
1139+
model_path: Path to the ONNX model to convert.
1140+
stage_timings: List to append (stage_name, elapsed) tuple to.
1141+
1142+
Returns:
1143+
The same model_path (overwritten with FP16 model).
1144+
"""
1145+
from ..onnx import load_onnx, save_onnx
1146+
from ..optim.fp16 import convert_to_fp16
1147+
from ..utils.console import StageLive
1148+
1149+
with StageLive("fp16", console) as sl:
1150+
sl.set_status("Converting to FP16...")
1151+
t0 = time.monotonic()
1152+
1153+
model = load_onnx(model_path)
1154+
model = convert_to_fp16(model, keep_io_types=True)
1155+
save_onnx(model, model_path)
1156+
1157+
elapsed = time.monotonic() - t0
1158+
sl.set_done(elapsed)
1159+
sl.detail("[dim]I/O types preserved as FP32[/dim]")
1160+
sl.artifact(str(model_path), _safe_size(model_path))
1161+
sl.blank()
1162+
1163+
stage_timings.append(("FP16", elapsed))
1164+
return model_path
1165+
1166+
11221167
def _run_quantize_stage(
11231168
*,
11241169
config: WinMLBuildConfig,
@@ -1378,6 +1423,8 @@ def _name(base: str) -> str:
13781423

13791424
stage_timings.append(("Export", _export_elapsed))
13801425

1426+
_precision = extra_kwargs.pop("precision", None)
1427+
13811428
# ── Optimize stage ───────────────────────────────────────────
13821429
current_path, _ = _run_optimize_stage(
13831430
config=config,
@@ -1395,13 +1442,24 @@ def _name(base: str) -> str:
13951442
# Persist config after autoconf
13961443
config_path.write_text(json.dumps(config.to_dict(), indent=2))
13971444

1398-
# ── Quantize stage ───────────────────────────────────────────
1399-
current_path = _run_quantize_stage(
1400-
config=config,
1401-
current_path=current_path,
1402-
quantized_path=quantized_path,
1403-
stage_timings=stage_timings,
1404-
)
1445+
# ── FP16 conversion (when --precision fp16) ──────────────────
1446+
if _precision == "fp16":
1447+
current_path = _run_fp16_stage(
1448+
model_path=current_path,
1449+
stage_timings=stage_timings,
1450+
)
1451+
1452+
# ── Quantize stage (skipped when FP16 — incompatible) ────────
1453+
if _precision == "fp16" and config.quant is not None:
1454+
print_stage_skip(console, "quantize", "(incompatible with --precision fp16)")
1455+
stage_timings.append(("Quantize", None))
1456+
else:
1457+
current_path = _run_quantize_stage(
1458+
config=config,
1459+
current_path=current_path,
1460+
quantized_path=quantized_path,
1461+
stage_timings=stage_timings,
1462+
)
14051463

14061464
# ── Compile stage ────────────────────────────────────────────
14071465
current_path = _run_compile_stage(
@@ -1437,6 +1495,7 @@ def _build_onnx_pipeline(
14371495

14381496
max_iters: int = extra_kwargs.pop("hack_max_optim_iterations", 3)
14391497
allow_unsupported_nodes: bool = extra_kwargs.pop("allow_unsupported_nodes", False)
1498+
_precision: str | None = extra_kwargs.pop("precision", None)
14401499

14411500
# ── Validate + setup ─────────────────────────────────────────
14421501
if not onnx_path.exists():
@@ -1490,13 +1549,24 @@ def _build_onnx_pipeline(
14901549

14911550
config_path.write_text(json.dumps(config.to_dict(), indent=2))
14921551

1493-
# ── Quantize stage ───────────────────────────────────────────
1494-
current_path = _run_quantize_stage(
1495-
config=config,
1496-
current_path=current_path,
1497-
quantized_path=quantized_path,
1498-
stage_timings=stage_timings,
1499-
)
1552+
# ── FP16 conversion (when --precision fp16) ──────────────────
1553+
if _precision == "fp16":
1554+
current_path = _run_fp16_stage(
1555+
model_path=current_path,
1556+
stage_timings=stage_timings,
1557+
)
1558+
1559+
# ── Quantize stage (skipped when FP16 — incompatible) ────────
1560+
if _precision == "fp16" and config.quant is not None:
1561+
print_stage_skip(console, "quantize", "(incompatible with --precision fp16)")
1562+
stage_timings.append(("Quantize", None))
1563+
else:
1564+
current_path = _run_quantize_stage(
1565+
config=config,
1566+
current_path=current_path,
1567+
quantized_path=quantized_path,
1568+
stage_timings=stage_timings,
1569+
)
15001570

15011571
# ── Compile stage ────────────────────────────────────────────
15021572
current_path = _run_compile_stage(

src/winml/modelkit/commands/export.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def _delete_onnx_with_external_data(onnx_path: Path) -> None:
130130
help='JSON with shape overrides (e.g., {"sequence_length": 2048, "height": 640}).',
131131
)
132132
@cli_utils.build_config_option()
133+
@cli_utils.precision_option(optional_message="When fp16, applies FP16 conversion after export.")
133134
@cli_utils.verbosity_options()
134135
@click.pass_context
135136
def export(
@@ -148,6 +149,7 @@ def export(
148149
export_config: Path | None,
149150
shape_config: Path | None,
150151
config_file: Path | None,
152+
precision: str | None,
151153
) -> None:
152154
r"""Export HuggingFace model to ONNX format with HTP.
153155
@@ -420,6 +422,17 @@ def export(
420422
)
421423
logger.debug("Export stats: %s", export_stats)
422424

425+
# Post-export FP16 conversion when --precision fp16 is specified
426+
if precision == "fp16":
427+
console.print("[bold]Converting to FP16...[/bold]")
428+
from ..onnx import load_onnx, save_onnx
429+
from ..optim.fp16 import convert_to_fp16
430+
431+
fp16_model = load_onnx(output_path)
432+
fp16_model = convert_to_fp16(fp16_model, keep_io_types=True)
433+
save_onnx(fp16_model, output_path)
434+
console.print("[dim]FP16 conversion applied (I/O kept as FP32)[/dim]")
435+
423436
# TODO: re-enable post-export optimization (shape inference, constant folding)
424437
# Disabled: needs validation that optimize_onnx preserves HTP hierarchy tags.
425438
# from ..optim.api import optimize_onnx

src/winml/modelkit/commands/optimize.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,21 @@ def capability_options(func: F) -> F:
180180
default=None,
181181
help="Configuration file (YAML/JSON)",
182182
)
183+
@cli_utils.precision_option(optional_message="Applies FP16 conversion after graph optimization.")
184+
@click.option(
185+
"--fp16-keep-io-types/--no-fp16-keep-io-types",
186+
"fp16_keep_io_types",
187+
default=True,
188+
show_default=True,
189+
help="Keep model I/O as FP32 when --precision fp16 (insert Cast at boundary)",
190+
)
191+
@click.option(
192+
"--fp16-op-block-list",
193+
"fp16_op_block_list",
194+
type=str,
195+
default=None,
196+
help="Comma-separated list of op types to keep in FP32 (e.g., LayerNorm,Softmax)",
197+
)
183198
@cli_utils.verbosity_options()
184199
@capability_options
185200
@click.pass_context # type: ignore[arg-type] # capability_options widens the signature; click stubs want positional-only ctx but we keep it keyword-callable for back-compat
@@ -190,6 +205,9 @@ def optimize(
190205
model: Path | None,
191206
output: Path | None,
192207
config: Path | None,
208+
precision: str | None,
209+
fp16_keep_io_types: bool,
210+
fp16_op_block_list: str | None,
193211
verbose: int,
194212
quiet: bool,
195213
**kwargs: Any,
@@ -224,6 +242,17 @@ def optimize(
224242
# Basic optimization with GELU fusion
225243
winml optimize -m model.onnx -o model_opt.onnx --enable-gelu-fusion
226244
245+
# Convert model to FP16 (after graph optimization)
246+
winml optimize -m model.onnx -o fp16.onnx --precision fp16
247+
248+
# FP16 without preserving I/O types
249+
winml optimize -m model.onnx -o fp16.onnx --precision fp16 \
250+
--no-fp16-keep-io-types
251+
252+
# FP16 with specific ops kept in FP32
253+
winml optimize -m model.onnx -o fp16.onnx --precision fp16 \
254+
--fp16-op-block-list LayerNorm,Softmax
255+
227256
# Use config file
228257
winml optimize -m model.onnx -c config.toml
229258
"""
@@ -406,6 +435,22 @@ def optimize(
406435
optimizer = Optimizer()
407436
optimized_model = optimizer.optimize(onnx_model, **optimizer_kwargs)
408437

438+
# Post-optimization FP16 conversion (command-layer, not a pipe)
439+
if precision == "fp16":
440+
from ..optim.fp16 import convert_to_fp16
441+
442+
console.print("[bold]Converting to FP16...[/bold]")
443+
op_block = (
444+
[s.strip() for s in fp16_op_block_list.split(",") if s.strip()]
445+
if fp16_op_block_list
446+
else None
447+
)
448+
optimized_model = convert_to_fp16(
449+
optimized_model,
450+
keep_io_types=fp16_keep_io_types,
451+
op_block_list=op_block,
452+
)
453+
409454
console.print("[bold]Saving optimized model...[/bold]")
410455
save_onnx(optimized_model, output)
411456

src/winml/modelkit/optim/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .api import optimize_onnx
2828
from .config import WinMLOptimizationConfig
2929
from .errors import ConfigurationError, ModelValidationError, OptimizationError
30+
from .fp16 import convert_to_fp16
3031
from .optimizer import Optimizer
3132
from .registry import (
3233
BoolCapability,
@@ -48,6 +49,7 @@
4849
"Optimizer",
4950
"WinMLOptimizationConfig",
5051
"auto_enable_dependencies",
52+
"convert_to_fp16",
5153
"optimize_onnx",
5254
"validate",
5355
"validate_dependencies",

src/winml/modelkit/optim/fp16.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# -------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
# --------------------------------------------------------------------------
5+
"""FP16 conversion utility for ONNX models.
6+
7+
Provides a single entry point for FP32→FP16 model conversion, used by
8+
all CLI commands (optimize, build, export) at the command layer.
9+
10+
This is NOT an optimizer pipe — FP16 is a precision transformation (like
11+
quantization), not a graph optimization. It runs after optimization and
12+
before quantization in the build pipeline.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
from typing import TYPE_CHECKING
19+
20+
21+
if TYPE_CHECKING:
22+
import onnx
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
def convert_to_fp16(
28+
model: onnx.ModelProto,
29+
*,
30+
keep_io_types: bool = True,
31+
op_block_list: list[str] | None = None,
32+
) -> onnx.ModelProto:
33+
"""Convert an ONNX model from FP32 to FP16 precision.
34+
35+
Uses onnxruntime.transformers.float16.convert_float_to_float16 internally.
36+
No new dependencies — ORT is already a project dependency.
37+
38+
Note: ORT's converter mutates the model in-place and returns the same object.
39+
40+
Args:
41+
model: Input ONNX ModelProto (will be mutated in-place by ORT).
42+
keep_io_types: If True, preserve FP32 model inputs/outputs by inserting
43+
Cast nodes at boundaries. Recommended for CPU-safe inference.
44+
op_block_list: Op types to keep in FP32 (e.g., ["LayerNorm", "Softmax"]).
45+
When None, ORT uses its DEFAULT_OP_BLOCK_LIST which includes ops
46+
known to be numerically unsafe in FP16 (e.g., TopK, CumSum, etc.).
47+
48+
Returns:
49+
The converted model (same object as input due to ORT in-place mutation).
50+
"""
51+
from onnx import TensorProto
52+
from onnxruntime.transformers.float16 import convert_float_to_float16
53+
54+
# Skip if model is already FP16 (check floating-point initializer dtypes)
55+
fp32_types = {TensorProto.FLOAT, TensorProto.DOUBLE, TensorProto.BFLOAT16}
56+
initializers = model.graph.initializer
57+
if initializers:
58+
float_inits = [t for t in initializers if t.data_type in fp32_types | {TensorProto.FLOAT16}]
59+
if float_inits and all(t.data_type == TensorProto.FLOAT16 for t in float_inits):
60+
logger.info("Model is already FP16 — skipping conversion.")
61+
return model
62+
63+
original_nodes = len(model.graph.node)
64+
65+
logger.info("Converting model to FP16...")
66+
if keep_io_types:
67+
logger.info(" Keeping I/O types as FP32")
68+
if op_block_list:
69+
logger.info(" Keeping ops in FP32: %s", op_block_list)
70+
71+
converted = convert_float_to_float16(
72+
model,
73+
keep_io_types=keep_io_types,
74+
op_block_list=op_block_list,
75+
)
76+
77+
# ORT's converter appends Cast nodes at the end of the node list (for
78+
# keep_io_types), which breaks topological ordering. Re-sort the graph
79+
# using ORT's own topological sort utility.
80+
if keep_io_types:
81+
from onnxruntime.transformers.onnx_model import OnnxModel
82+
83+
OnnxModel.graph_topological_sort(converted.graph)
84+
85+
converted_nodes = len(converted.graph.node)
86+
if converted_nodes != original_nodes:
87+
logger.info("FP16 conversion complete: %d -> %d nodes", original_nodes, converted_nodes)
88+
else:
89+
logger.info("FP16 conversion complete: %d nodes", converted_nodes)
90+
91+
return converted

0 commit comments

Comments
 (0)