Initialisation du repository de Beta

This commit is contained in:
Mathis 2026-02-06 22:23:20 +01:00
commit 14985f6dbb
9469 changed files with 1903273 additions and 0 deletions

View file

@ -0,0 +1,12 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
# --------------------------------------------------------------------------
import os
import sys
sys.path.append(os.path.dirname(__file__))
transformers_dir = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
if transformers_dir not in sys.path:
sys.path.append(transformers_dir)

View file

@ -0,0 +1,610 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import argparse
import ast
import datetime
import gc
import logging
import os
import sys
import time
import numpy as np
import psutil
import torch
import whisper
from benchmark_helper import measure_memory, setup_logger
from onnxruntime_extensions import get_library_path
from optimum.onnxruntime import ORTModelForSpeechSeq2Seq
from torch.profiler import ProfilerActivity, profile, record_function
from tqdm import trange
from transformers import AutoModelForSpeechSeq2Seq, WhisperConfig, WhisperProcessor
import onnxruntime as ort
logger = logging.getLogger(__name__)
def get_inputs(args: argparse.Namespace):
if args.benchmark_type not in {"hf-pt-eager", "hf-pt-compile", "hf-ort", "ort"}:
raise Exception("Unable to auto-detect inputs for provided model")
def load_via_ffmpeg():
audio = whisper.load_audio(args.audio_path)
audio = whisper.pad_or_trim(audio)
return audio
def load_via_numpy():
with open(args.audio_path, "rb") as f:
audio = np.asarray(list(f.read()), dtype=np.uint8)
audio = np.array([audio])
return audio
inputs = {
"max_length": args.max_length,
"min_length": args.min_length,
"num_beams": args.num_beams,
"num_return_sequences": args.num_return_sequences,
"length_penalty": args.length_penalty,
"repetition_penalty": args.repetition_penalty,
}
if args.benchmark_type == "ort":
# convert_to_onnx export or ONNX E2E solution created by Olive
for k, v in inputs.items():
inputs[k] = np.array([v], dtype=np.float32 if "penalty" in k else np.int32)
if args.has_decoder_input_ids:
inputs["decoder_input_ids"] = np.array([args.decoder_input_ids], dtype=np.int32)
if args.has_logits_processor:
inputs["logits_processor"] = np.array([args.logits_processor], dtype=np.int32)
if args.has_temperature:
inputs["temperature"] = np.array([args.temperature], dtype=np.float32)
# Measure time taken to load audio file
logger.info(f"Load audio: {args.audio_path}")
load_audio_fn = lambda onnx_e2e: load_via_numpy() if onnx_e2e else load_via_ffmpeg() # noqa: E731
time_fn(args, load_audio_fn, args.has_audio_stream)
audio_data = load_audio_fn(args.has_audio_stream)
if args.has_audio_stream:
# ONNX E2E solution created by Olive
inputs["audio_stream"] = audio_data
return inputs
# Measure time taken to get input features
logger.info("Feature extraction: ")
return_type = "np" if args.benchmark_type == "ort" else "pt"
processor_fn = lambda audio: args.processor.feature_extractor( # noqa: E731
[audio], return_tensors=return_type, sampling_rate=args.sampling_rate
).input_features
time_fn(args, processor_fn, audio_data)
input_features = processor_fn(audio_data)
if args.benchmark_type == "ort":
# convert_to_onnx export
inputs["input_features"] = input_features
return inputs
inputs["inputs"] = input_features.to(
dtype=torch.float16 if args.use_fp16 else torch.float32, device=args.target_device
)
inputs["no_repeat_ngram_size"] = args.no_repeat_ngram_size
inputs["early_stopping"] = True
inputs["use_cache"] = True
if args.decoder_input_ids:
inputs["forced_decoder_ids"] = args.decoder_input_ids
return inputs
def get_model(args: argparse.Namespace):
model, sess_options = None, None
start_time, end_time = None, None
# There are multiple sources that the model could come from:
# 1) Benchmark Whisper from Hugging Face
# 2) Benchmark Whisper ONNX model from Optimum export (without pre/post processing)
# 3) Benchmark Whisper ONNX E2E model from Olive (with pre/post processing)
if args.benchmark_type in {"hf-pt-eager", "hf-pt-compile"}:
source = args.hf_pt_model_path if args.hf_pt_model_path else args.model_name
start_time = time.time()
model = AutoModelForSpeechSeq2Seq.from_pretrained(
source,
torch_dtype=torch.float16 if args.use_fp16 else torch.float32,
use_cache=True,
).to(args.target_device)
end_time = time.time()
if args.benchmark_type == "hf-pt-compile":
model = torch.compile(model)
elif args.benchmark_type in {"hf-ort", "ort"}:
sess_options = ort.SessionOptions()
sess_options.enable_profiling = args.profile
sess_options.register_custom_ops_library(get_library_path())
if args.verbose:
sess_options.log_verbosity_level = 1
sess_options.log_severity_level = 1
if args.tune:
ort.set_default_logger_severity(0)
ort.set_default_logger_verbosity(0)
else:
raise Exception(f"Cannot recognize {args.benchmark_type}")
if args.benchmark_type == "hf-ort":
# Optimum export
provider = args.execution_provider[0] if type(args.execution_provider) is tuple else args.execution_provider
provider_options = args.execution_provider[1] if type(args.execution_provider) is tuple else None
start_time = time.time()
model = ORTModelForSpeechSeq2Seq.from_pretrained(
args.hf_ort_dir_path,
provider=provider,
provider_options=provider_options,
session_options=sess_options,
use_io_binding=True, # Avoid memory copy overhead
)
end_time = time.time()
if args.benchmark_type == "ort":
# convert_to_onnx.py export
logger.info(f"Loading model from {args.ort_model_path}")
start_time = time.time()
model = ort.InferenceSession(
args.ort_model_path,
sess_options,
providers=[args.execution_provider],
)
end_time = time.time()
logger.info(f"Loaded model in {end_time - start_time} s")
return model
def time_fn(args, fn, inputs):
warmup_inputs = inputs[0] if type(inputs) is tuple else inputs
benchmark_inputs = inputs[1] if type(inputs) is tuple else inputs
torch_device = torch.device(args.target_device)
# Warm up
warmup_range = (
range(args.warmup_runs)
if args.benchmark_type == "ort"
else trange(args.warmup_runs, file=sys.stdout, desc="Warm up")
)
if args.verbose:
outputs = fn(warmup_inputs)
logger.info(outputs)
for _ in warmup_range:
fn(warmup_inputs)
# Benchmark
if args.device != "cpu":
torch.cuda.synchronize(torch_device)
start_time = time.time()
bench_range = (
range(args.num_runs)
if args.benchmark_type == "ort"
else trange(args.num_runs, file=sys.stdout, desc="Benchmark")
)
for _ in bench_range:
fn(benchmark_inputs)
if args.device != "cpu":
torch.cuda.synchronize(torch_device)
end_time = time.time()
# Newline print after trange in order to print metrics on new lines without progress bar on same line
if args.benchmark_type != "ort":
logger.info("")
batch_size = 1
latency = (end_time - start_time) / args.num_runs
throughput = batch_size / latency
logger.info(f"Latency: {latency} s")
logger.info(f"Throughput: {throughput} qps")
return
def profile_fn(args, fn, inputs, inputs_type):
# Filename prefix format:
# "<benchmark-type>-<precision>-<device>_<inference-step>_<inputs-type>_<current-time>"
prefix = f"{args.benchmark_type.lower()}-{args.precision}-{args.device}_{fn.__name__.replace('_', '-')}_{inputs_type}_{datetime.datetime.now():%Y-%m-%d_%H:%M:%S}"
filename = None
if args.benchmark_type in {"hf-pt-eager", "hf-pt-compile"}:
# Profile PyTorch kernels
with profile( # noqa: SIM117
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapes=True, profile_memory=True
) as prof:
with record_function("model_inference"):
fn(inputs)
prof_data = prof.key_averages(group_by_stack_n=5).table(sort_by=args.pt_filter_by, row_limit=args.pt_num_rows)
filename = os.path.join(args.log_folder, f"{prefix}.log")
with open(filename, "w") as f:
f.write(prof_data)
else:
# Profile ORT kernels
fn(inputs)
# Set new log name for ORT profile log generated
filename = f"{prefix}.json"
return filename
def measure_fn(args, fn, inputs):
# Measure CPU usage
pid = os.getpid()
process = psutil.Process(pid)
process.cpu_percent(interval=0.1)
fn(inputs)
logger.info(f"CPU usage: {process.cpu_percent(interval=None)}%")
# Measure memory usage
gc.collect()
torch.cuda.empty_cache()
measure_memory(is_gpu=(args.device != "cpu"), func=lambda: fn(inputs), monitor_type=args.monitor_type)
# Flush output so memory usage is printed
sys.stdout.flush()
def run_hf_inference(args, inputs, model):
# Inference steps to measure
def get_pred_ids(inputs):
# Inference pass with predicted token ids generation
predicted_ids = model.generate(**inputs)
return predicted_ids
def gen_and_dec(inputs):
# Inference pass with generation and decoding
predicted_ids = get_pred_ids(inputs)
transcription = []
for _ in range(args.num_return_sequences):
transcription.append(args.processor.batch_decode(predicted_ids, skip_special_tokens=True)[0])
return predicted_ids, transcription
# Examples of other inference steps that can be measured:
# To use, uncomment the function and assign it to `generate_fn`
# def get_logits(inputs):
# # Inference pass without decoding
# outputs = model(**inputs)
# return outputs
generate_fn = gen_and_dec
if args.benchmark_type == "hf-pt-compile":
# Run forward pass once with each set of inputs to process through Dynamo
generate_fn(inputs)
if args.profile:
new_logname = profile_fn(args, generate_fn, inputs, "gen-and-dec")
if args.benchmark_type == "hf-ort":
# Rename log files per model component and turn profiling off to stop appending to log
new_prefix = new_logname[: -len(".json")]
old_logname = model.encoder.session.end_profiling()
new_logname = new_prefix + "-encoder.json"
if os.path.isfile(old_logname):
logger.warning(f"Renaming {old_logname} to {new_logname}")
os.rename(old_logname, os.path.join(args.log_folder, new_logname))
old_logname = model.decoder.session.end_profiling()
new_logname = new_prefix + "-decoder.json"
if os.path.isfile(old_logname):
logger.warning(f"Renaming {old_logname} to {new_logname}")
os.rename(old_logname, os.path.join(args.log_folder, new_logname))
old_logname = model.decoder_with_past.session.end_profiling()
new_logname = new_prefix + "-decoder-with-past.json"
if os.path.isfile(old_logname):
logger.warning(f"Renaming {old_logname} to {new_logname}")
os.rename(old_logname, os.path.join(args.log_folder, new_logname))
return
# PyTorch evaluations
logger.info("\nEvaluating PyTorch...")
time_fn(args, generate_fn, inputs)
predicted_ids, transcription = generate_fn(inputs)
logger.info(f"Generated token length: {len(predicted_ids[0])} tokens")
logger.info(f"Transcription: {transcription[0]}")
measure_fn(args, generate_fn, inputs)
def run_ort_inference(args, inputs, model):
def prepare_ort_inputs(inputs, warmup=False):
# Check that all model inputs will be provided
model_inputs = {model_input.name for model_input in model.get_inputs()}
user_inputs = set(inputs.keys())
missing_inputs = model_inputs - user_inputs
if len(missing_inputs):
logger.error(f"The following model inputs are missing: {missing_inputs}")
raise Exception("There are missing inputs to the model. Please add them and try again.")
if warmup and args.tune:
inputs["min_length"] = inputs["max_length"]
# Remove unnecessary inputs from model inputs
unnecessary_inputs = user_inputs - model_inputs
if len(unnecessary_inputs):
for unnecessary_input in unnecessary_inputs:
logger.info(f"Removing unnecessary input '{unnecessary_input}' from user provided inputs")
del inputs[unnecessary_input]
# Add IO bindings for non-CPU execution providers
if args.device != "cpu":
io_binding = model.io_binding()
for k, v in inputs.items():
io_binding.bind_cpu_input(k, v)
for output in model.get_outputs():
io_binding.bind_output(output.name, device_type=args.device, device_id=args.device_id)
return io_binding
return inputs
def with_io_binding(io_binding):
# Inference pass with IO binding
model.run_with_iobinding(io_binding)
return io_binding
def without_io_binding(inputs):
# Inference pass without IO binding
outputs = model.run(None, inputs)
return outputs
def handle_output(output):
if args.eos_token_id in output:
first_end = np.where(output == args.eos_token_id)[0][0]
return output[: first_end + 1]
return output
generate_fn = with_io_binding if args.device != "cpu" else without_io_binding
ort_inputs = prepare_ort_inputs(inputs)
if args.profile:
new_logname = profile_fn(args, generate_fn, ort_inputs, "e2e")
# Turn profiling off to stop appending to log file
old_logname = model.end_profiling()
logger.warning(f"Renaming {old_logname} to {new_logname}")
os.rename(old_logname, os.path.join(args.log_folder, new_logname))
return
# ORT evaluation
logger.info("\nEvaluating ONNX Runtime...")
ort_evaluate_inputs = ort_inputs
if args.tune:
ort_warmup_inputs = prepare_ort_inputs(inputs, warmup=True)
ort_evaluate_inputs = (ort_warmup_inputs, ort_inputs)
time_fn(args, generate_fn, ort_evaluate_inputs)
ort_outputs = generate_fn(ort_inputs)
if args.device != "cpu":
ort_outputs = ort_outputs.copy_outputs_to_cpu()
ort_outputs = ort_outputs[0]
if args.has_audio_stream:
# ONNX E2E model from Olive produces transcribed output
logger.info(f"Transcription: {ort_outputs[0][0]}")
else:
# convert_to_onnx model produces generated ids
actual_output = handle_output(ort_outputs[0][0])
logger.info(f"Generated token length: {len(actual_output)} tokens")
transcription = args.processor.batch_decode(ort_outputs[0], skip_special_tokens=True)[0]
# print to stdout as the output for comparison
print(f"{transcription}")
measure_fn(args, generate_fn, ort_inputs)
def run_inference(args, inputs, model):
if args.benchmark_type in {"hf-pt-eager", "hf-pt-compile", "hf-ort"}:
run_hf_inference(args, inputs, model)
elif args.benchmark_type == "ort":
run_ort_inference(args, inputs, model)
else:
raise Exception(f"Cannot recognize {args.benchmark_type}")
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"-bt",
"--benchmark-type",
type=str,
required=True,
choices=["hf-pt-eager", "hf-pt-compile", "hf-ort", "ort"],
)
parser.add_argument(
"-m",
"--model-name",
type=str,
required=True,
help="Hugging Face name of model (e.g. 'openai/whisper-large-v2')",
)
parser.add_argument(
"-p",
"--precision",
type=str,
required=True,
default="fp32",
choices=["int8", "fp16", "fp32"],
help="Precision for model. For ONNX models, the model's precision should be set before running this script.",
)
parser.add_argument(
"--hf-pt-model-path",
type=str,
default="",
help="Path to directory containing all PyTorch files (e.g. tokenizer, PyTorch model)",
)
parser.add_argument(
"--hf-ort-dir-path",
type=str,
default="",
help="Path to directory containing all ONNX files (e.g. tokenizer, encoder, decoder, decoder_with_past)",
)
parser.add_argument(
"--ort-model-path",
type=str,
default="",
help="Path to ONNX model",
)
# Args for running and evaluating the model
parser.add_argument("-a", "--audio-path", type=str, required=True, help="Path to audio file for E2E evaluation")
parser.add_argument(
"-d",
"--device",
type=str,
default="cuda" if torch.cuda.is_available() else "cpu",
choices=["cpu", "cuda", "rocm"],
)
parser.add_argument("-id", "--device-id", type=int, default=0)
parser.add_argument("-w", "--warmup-runs", type=int, default=5)
parser.add_argument("-n", "--num-runs", type=int, default=10)
parser.add_argument("--seed", type=int, default=2)
# Optional args:
parser.add_argument("--sampling-rate", type=int, default=16000, help="Sampling rate for audio (in Hz)")
# Args for decoding logic
# Required args:
parser.add_argument("--max-length", type=int, default=448)
parser.add_argument("--min-length", type=int, default=0)
parser.add_argument("--num-beams", type=int, default=1)
parser.add_argument("--num-return-sequences", type=int, default=1)
parser.add_argument("--length-penalty", type=float, default=1.0)
parser.add_argument("--repetition-penalty", type=float, default=1.0)
parser.add_argument("--no-repeat-ngram-size", type=int, default=3)
# Optional args for E2E solution:
parser.add_argument(
"--decoder-input-ids",
type=str,
default="[]",
help="The forced decoder ids for generation. Format is [start token, timestamp token, language token, task token]. Default is [start token]. See `decoder_input_ids` in https://github.com/microsoft/Olive/tree/main/examples/whisper for details.",
)
parser.add_argument(
"--logits-processor",
type=int,
default=1,
help="Whether to use timestamps logits processor or not (0 for false, 1 for true).",
)
parser.add_argument(
"--temperature",
type=float,
default=1.0,
help="Temperature value for generation.",
)
# Args for accessing detailed info
parser.add_argument("--profile", default=False, action="store_true")
parser.add_argument(
"--pt-filter-by", type=str, default="self_cpu_time_total", help="What to filter PyTorch profiler by"
)
parser.add_argument("--pt-num-rows", type=int, default=1000, help="Number of rows for PyTorch profiler to display")
parser.add_argument("--verbose", default=False, action="store_true")
parser.add_argument("--log-folder", type=str, default=os.path.join("."), help="Folder to cache log files")
parser.add_argument(
"--tune",
default=False,
action="store_true",
help="Only used by ROCm EP, enable TunableOp tuning to select fastest kernel",
)
args = parser.parse_args()
# Set seed properties
np.random.seed(args.seed)
torch.manual_seed(args.seed)
args.monitor_type = args.device
# Set runtime properties
if "ort" in args.benchmark_type:
args.execution_provider = f"{args.device.upper()}ExecutionProvider"
if args.execution_provider == "CUDAExecutionProvider":
args.execution_provider = (args.execution_provider, {"device_id": args.device_id})
elif args.execution_provider == "ROCMExecutionProvider":
args.execution_provider = (
args.execution_provider,
{
"device_id": args.device_id,
"tunable_op_enable": 1,
"tunable_op_tuning_enable": 1 if args.tune else 0,
},
)
args.device = "cuda"
# Check that model paths have been specified for any benchmarking with ORT
if args.benchmark_type == "hf-ort":
assert args.hf_ort_dir_path, "Please specify a path to `--hf-ort-dir-path`"
if args.benchmark_type == "ort":
assert args.ort_model_path, "Please specify a path to `--ort-model-path`"
# Convert decoder_input_ids string to list of ids
# (e.g. "[1, 50257]" for Hugging Face or "[50257]" for ORT)
args.decoder_input_ids = ast.literal_eval(args.decoder_input_ids)
return args
def main():
args = parse_args()
setup_logger(args.verbose)
logger.info(args.__dict__)
torch.backends.cudnn.benchmark = True
config = WhisperConfig.from_pretrained(args.model_name)
processor = WhisperProcessor.from_pretrained(args.model_name)
target_device = f"cuda:{args.device_id}" if args.device != "cpu" else args.device
use_fp16 = args.precision == "fp16"
setattr(args, "processor", processor) # noqa: B010
setattr(args, "target_device", target_device) # noqa: B010
setattr(args, "use_fp16", use_fp16) # noqa: B010
setattr(args, "has_audio_stream", False) # noqa: B010
setattr(args, "eos_token_id", config.eos_token_id) # noqa: B010
logger.info(f"Forced decoder prompt ids: {args.decoder_input_ids}")
# Measure cost to transcribe audio
model = get_model(args)
if args.benchmark_type == "ort":
# Check for optional inputs that could have been added during export
ort_model_inputs = {model_input.name for model_input in model.get_inputs()}
args.has_audio_stream = "audio_stream" in ort_model_inputs
setattr(args, "has_decoder_input_ids", "decoder_input_ids" in ort_model_inputs) # noqa: B010
setattr(args, "has_logits_processor", "logits_processor" in ort_model_inputs) # noqa: B010
setattr(args, "has_temperature", "temperature" in ort_model_inputs) # noqa: B010
if args.decoder_input_ids == []:
args.decoder_input_ids = [config.decoder_start_token_id]
inputs = get_inputs(args)
run_inference(args, inputs, model)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,526 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import argparse
import datetime
import json
import logging
import os
import subprocess
import librosa
import torch
from benchmark_helper import setup_logger
from metrics import BenchmarkRecord
from transformers import WhisperConfig, WhisperProcessor
logger = logging.getLogger(__name__)
def get_args():
parser = argparse.ArgumentParser()
parser.add_argument(
"-a",
"--audio-path",
type=str,
required=True,
help="Path to folder of audio files for E2E evaluation",
)
parser.add_argument(
"-l",
"--language",
default=None,
help="Language of audio file",
)
parser.add_argument(
"-t",
"--task",
default=None,
choices=["transcribe", "translate"],
help="Task to complete",
)
parser.add_argument(
"-w",
"--warmup-runs",
type=int,
default=5,
)
parser.add_argument(
"-n",
"--num-runs",
type=int,
default=10,
)
parser.add_argument(
"--hf-pt-eager",
default=False,
action="store_true",
help="Benchmark in PyTorch without `torch.compile`",
)
parser.add_argument(
"--hf-pt-compile",
default=False,
action="store_true",
help="Benchmark in PyTorch with `torch.compile`",
)
parser.add_argument(
"--hf-ort-dir-path",
type=str,
help="Path to folder containing ONNX models for Optimum + ORT benchmarking",
)
parser.add_argument(
"--ort-model-path",
type=str,
help="Path to ONNX model for ORT benchmarking",
)
parser.add_argument(
"--model-name",
type=str,
required=True,
help="Model name in Hugging Face (e.g. openai/whisper-large-v2)",
)
parser.add_argument(
"--precision",
type=str,
required=True,
choices=["int8", "fp16", "fp32"],
help="Precision to run model",
)
parser.add_argument(
"--device",
type=str,
required=True,
choices=["cpu", "cuda", "rocm"],
help="Device to benchmark models",
)
parser.add_argument(
"--device-id",
type=int,
default=0,
help="GPU device ID",
)
parser.add_argument(
"--verbose",
default=False,
action="store_true",
help="Print detailed logs",
)
parser.add_argument(
"--timeout",
type=int,
default=5,
help="Number of mins to attempt the benchmark before moving on",
)
parser.add_argument(
"--log-folder",
type=str,
default=None,
help="Path to folder to save logs and results",
)
parser.add_argument("--tune", default=False, action="store_true")
args = parser.parse_args()
setattr(args, "model_size", args.model_name.split("/")[-1].replace(".", "-")) # noqa: B010
log_folder_name = f"./{args.model_size}-{args.precision}"
if not args.log_folder:
args.log_folder = log_folder_name
os.makedirs(args.log_folder, exist_ok=True)
# Convert timeout value to secs
args.timeout *= 60
return args
def process_log_file(device_id, log_file, base_results):
entries = []
# Detect steps in speech pipeline
step = None
load_audio_pattern = "Load audio: "
feat_ext_pattern = "Feature extraction: "
pytorch_pattern = "Evaluating PyTorch..."
onnxruntime_pattern = "Evaluating ONNX Runtime..."
load_audio_latency_s, load_audio_throughput_s = None, None
feat_ext_latency_s, feat_ext_throughput_s = None, None
token_length, latency_s, per_token_latency_s, per_token_latency_ms = None, None, None, None
throughput, memory = None, None
# Detect metrics
latency_pattern = "Latency: "
throughput_pattern = "Throughput: "
token_length_pattern = "Generated token length: "
memory_pattern = "peak="
with open(log_file) as f:
for input_line in f:
line = input_line.replace("\n", "")
# Get step in speech recognition pipeline
if load_audio_pattern in line:
step = "load-audio"
elif feat_ext_pattern in line:
step = "feature-extraction"
elif pytorch_pattern in line or onnxruntime_pattern in line:
step = "process"
# Check metrics
if latency_pattern in line:
latency_s = float(line[len(latency_pattern) : line.rfind(" ")])
elif throughput_pattern in line:
throughput = float(line[len(throughput_pattern) : line.rfind(" ")])
if step == "load-audio":
load_audio_latency_s, load_audio_throughput_s = latency_s, throughput
step = None
if step == "feature-extraction":
feat_ext_latency_s, feat_ext_throughput_s = latency_s, throughput
step = None
elif token_length_pattern in line:
token_length = int(line[len(token_length_pattern) : line.rfind(" ")])
per_token_latency_s = latency_s / token_length
per_token_latency_ms = per_token_latency_s * 1000
elif memory_pattern in line:
if "CPU" in line:
# Example format for log entry:
# CPU memory usage: before=1000.0 MB, peak=2000.0 MB
memory = float(line[line.rfind("=") + 1 : line.rfind(" MB")]) / 1000
else:
# Example format for log entry:
# GPU memory usage: before=[{'device_id': 0, 'name': 'Tesla V100-PCIE-16GB', 'max_used_MB': 1638.875}, {'device_id': 1, 'name': 'Tesla V100-PCIE-16GB', 'max_used_MB': 236.875}, peak=[{'device_id': 0, 'name': 'Tesla V100-PCIE-16GB', 'max_used_MB': 1780.875}, {'device_id': 1, 'name': 'Tesla V100-PCIE-16GB', 'max_used_MB': 236.875}]
peak = line[line.find(memory_pattern) + len(memory_pattern) :].replace("'", '"')
usage = json.loads(peak)[device_id]["max_used_MB"]
memory = float(usage) / 1000
# Calculate real-time factor (RTF):
# RTF = total latency / audio duration
total_latency = (
(load_audio_latency_s if load_audio_latency_s else 0)
+ (feat_ext_latency_s if feat_ext_latency_s else 0)
+ (latency_s if latency_s else 0)
)
audio_duration = base_results[-1]
rtf = (total_latency / audio_duration) if audio_duration else -1
logger.info(f"Total latency: {total_latency} s")
logger.info(f"Audio duration: {audio_duration} s")
logger.info(f"Real-time factor: {rtf}")
# Append log entry to list of entries
entry = base_results + [ # noqa: RUF005
token_length,
load_audio_latency_s,
load_audio_throughput_s,
feat_ext_latency_s if feat_ext_latency_s else -1,
feat_ext_throughput_s if feat_ext_throughput_s else -1,
latency_s,
per_token_latency_ms,
throughput,
memory,
rtf,
]
entries.append(entry)
return entries
def save_results(results, filename):
import pandas as pd # noqa: PLC0415
df = pd.DataFrame(
results,
columns=[
"Warmup Runs",
"Measured Runs",
"Model Name",
"Engine",
"Precision",
"Device",
"Audio File",
"Duration (s)",
"Token Length",
"Load Audio Latency (s)",
"Load Audio Throughput (qps)",
"Feature Extractor Latency (s)",
"Feature Extractor Throughput (qps)",
"Latency (s)",
"Per Token Latency (ms/token)",
"Throughput (qps)",
"Memory (GB)",
"Real Time Factor (RTF)",
],
)
# Set column types
df["Warmup Runs"] = df["Warmup Runs"].astype("int")
df["Measured Runs"] = df["Measured Runs"].astype("int")
df["Duration (s)"] = df["Duration (s)"].astype("float")
df["Token Length"] = df["Token Length"].astype("int")
df["Load Audio Latency (s)"] = df["Load Audio Latency (s)"].astype("float")
df["Load Audio Throughput (qps)"] = df["Load Audio Throughput (qps)"].astype("float")
df["Feature Extractor Latency (s)"] = df["Feature Extractor Latency (s)"].astype("float")
df["Feature Extractor Throughput (qps)"] = df["Feature Extractor Throughput (qps)"].astype("float")
df["Latency (s)"] = df["Latency (s)"].astype("float")
df["Per Token Latency (ms/token)"] = df["Per Token Latency (ms/token)"].astype("float")
df["Throughput (qps)"] = df["Throughput (qps)"].astype("float")
df["Memory (GB)"] = df["Memory (GB)"].astype("float")
df["Real Time Factor (RTF)"] = df["Real Time Factor (RTF)"].astype("float")
# get package name and version
import pkg_resources # noqa: PLC0415
installed_packages = pkg_resources.working_set
installed_packages_list = sorted(
[f"{i.key}=={i.version}" for i in installed_packages if i.key in ["onnxruntime", "onnxruntime-gpu"]]
)
ort_pkg_name = ""
ort_pkg_version = ""
if installed_packages_list:
ort_pkg_name = installed_packages_list[0].split("==")[0]
ort_pkg_version = installed_packages_list[0].split("==")[1]
# Save results to csv with standard format
records = []
for _, row in df.iterrows():
if row["Engine"] == "onnxruntime":
record = BenchmarkRecord(
row["Model Name"], row["Precision"], row["Engine"], row["Device"], ort_pkg_name, ort_pkg_version
)
else:
record = BenchmarkRecord(
row["Model Name"], row["Precision"], row["Engine"], row["Device"], torch.__name__, torch.__version__
)
record.config.customized["audio_file"] = row["Audio File"]
record.config.warmup_runs = row["Warmup Runs"]
record.config.measured_runs = row["Measured Runs"]
record.metrics.customized["duration"] = row["Duration (s)"]
record.metrics.customized["token_length"] = row["Token Length"]
record.metrics.customized["load_audio_latency"] = row["Load Audio Latency (s)"]
record.metrics.customized["load_audio_throughput"] = row["Load Audio Throughput (qps)"]
record.metrics.customized["feature_extractor_latency_s"] = row["Feature Extractor Latency (s)"]
record.metrics.customized["feature_extractor_throughput_qps"] = row["Feature Extractor Throughput (qps)"]
record.metrics.customized["per_token_latency_ms"] = row["Per Token Latency (ms/token)"]
record.metrics.customized["rtf"] = row["Real Time Factor (RTF)"]
record.metrics.latency_ms_mean = row["Latency (s)"] * 1000
record.metrics.throughput_qps = row["Throughput (qps)"]
record.metrics.max_memory_usage_GB = row["Memory (GB)"]
records.append(record)
BenchmarkRecord.save_as_csv(filename, records)
BenchmarkRecord.save_as_json(filename.replace(".csv", ".json"), records)
logger.info(f"Results saved in {filename}!")
def benchmark(args, benchmark_cmd, engine, audio_file, duration):
log_filename = f"{engine}_{datetime.datetime.now():%Y-%m-%d_%H:%M:%S}.log"
log_path = os.path.join(args.log_folder, log_filename)
with open(log_path, "w") as log_file:
process = subprocess.Popen(benchmark_cmd, stdout=log_file, stderr=log_file)
try:
process.wait(args.timeout)
except subprocess.TimeoutExpired:
process.kill()
# Create entries for csv
logger.info("Gathering data from log files...")
base_results = [
args.warmup_runs,
args.num_runs,
args.model_name,
engine,
args.precision,
args.device,
audio_file,
duration,
]
results = process_log_file(args.device_id, log_path, base_results)
return results
def main():
args = get_args()
setup_logger(args.verbose)
logger.info(args.__dict__)
torch.backends.cudnn.benchmark = True
config = WhisperConfig.from_pretrained(args.model_name)
processor = WhisperProcessor.from_pretrained(args.model_name)
# Calculate forced decoder input ids
hf_forced_decoder_ids = processor.get_decoder_prompt_ids(language=args.language, task=args.task)
ort_forced_decoder_ids = [config.decoder_start_token_id] + [token_id[1] for token_id in hf_forced_decoder_ids]
hf_decoder_input_ids_cmd = (
["--decoder-input-ids", str(hf_forced_decoder_ids)] if args.language and args.task else []
)
ort_decoder_input_ids_cmd = (
["--decoder-input-ids", str(ort_forced_decoder_ids)] if args.language and args.task else []
)
ort_tune_cmd = ["--tune"] if args.tune else []
all_results = []
for audio_file in os.listdir(args.audio_path):
audio_path = os.path.join(args.audio_path, audio_file)
try:
duration = librosa.get_duration(path=audio_path)
except Exception as e:
duration = -1
logger.warning(f"An error occurred while trying to calculate the audio duration: {e}", exc_info=True)
logger.warning(
f"If you get an error that says:\n\tsoundfile.LibsndfileError: Error opening '{audio_file}': File contains data in an unknown format.\nyou may not have installed `ffmpeg` in addition to installing `librosa`."
)
logger.info(f"Testing {audio_path}...")
# Benchmark PyTorch without torch.compile
if args.hf_pt_eager:
benchmark_cmd = [ # noqa: RUF005
"python",
"-m",
"models.whisper.benchmark",
"--audio-path",
audio_path,
"--benchmark-type",
"hf-pt-eager",
"--model-name",
args.model_name,
"--precision",
args.precision,
"--device",
args.device,
"--device-id",
str(args.device_id),
"--warmup-runs",
str(args.warmup_runs),
"--num-runs",
str(args.num_runs),
"--log-folder",
args.log_folder,
] + hf_decoder_input_ids_cmd
logger.info("Benchmark PyTorch without torch.compile")
results = benchmark(args, benchmark_cmd, "pytorch-eager", audio_file, duration)
all_results.extend(results)
# Benchmark PyTorch with torch.compile
if args.hf_pt_compile:
benchmark_cmd = [ # noqa: RUF005
"python",
"-m",
"models.whisper.benchmark",
"--audio-path",
audio_path,
"--benchmark-type",
"hf-pt-compile",
"--model-name",
args.model_name,
"--precision",
args.precision,
"--device",
args.device,
"--device-id",
str(args.device_id),
"--warmup-runs",
str(args.warmup_runs),
"--num-runs",
str(args.num_runs),
"--log-folder",
args.log_folder,
] + hf_decoder_input_ids_cmd
logger.info("Benchmark PyTorch with torch.compile")
results = benchmark(args, benchmark_cmd, "pytorch-compile", audio_file, duration)
all_results.extend(results)
# Benchmark Optimum + ONNX Runtime
if args.hf_ort_dir_path:
benchmark_cmd = [ # noqa: RUF005
"python",
"-m",
"models.whisper.benchmark",
"--audio-path",
audio_path,
"--benchmark-type",
"hf-ort",
"--hf-ort-dir-path",
args.hf_ort_dir_path,
"--model-name",
args.model_name,
"--precision",
args.precision,
"--device",
args.device,
"--device-id",
str(args.device_id),
"--warmup-runs",
str(args.warmup_runs),
"--num-runs",
str(args.num_runs),
"--log-folder",
args.log_folder,
] + hf_decoder_input_ids_cmd
logger.info("Benchmark Optimum + ONNX Runtime")
results = benchmark(args, benchmark_cmd, "optimum-ort", audio_file, duration)
all_results.extend(results)
# Benchmark ONNX Runtime
if args.ort_model_path:
benchmark_cmd = (
[ # noqa: RUF005
"python",
"-m",
"models.whisper.benchmark",
"--audio-path",
audio_path,
"--benchmark-type",
"ort",
"--ort-model-path",
args.ort_model_path,
"--model-name",
args.model_name,
"--precision",
args.precision,
"--device",
args.device,
"--device-id",
str(args.device_id),
"--warmup-runs",
str(args.warmup_runs),
"--num-runs",
str(args.num_runs),
"--log-folder",
args.log_folder,
]
+ ort_decoder_input_ids_cmd
+ ort_tune_cmd
)
logger.info("Benchmark ONNX Runtime")
results = benchmark(args, benchmark_cmd, "onnxruntime", audio_file, duration)
all_results.extend(results)
csv_file = f"{args.model_size}-{args.precision}_{datetime.datetime.now():%Y-%m-%d_%H:%M:%S}.csv"
save_results(all_results, os.path.join(args.log_folder, csv_file))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,573 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import argparse
import logging
import os
import torch
from benchmark_helper import Precision, create_onnxruntime_session, prepare_environment, setup_logger
from whisper_chain import chain_model
from whisper_encoder import WhisperEncoder
from whisper_helper import PRETRAINED_WHISPER_MODELS, WhisperHelper
from onnxruntime import quantization
logger = logging.getLogger("")
PROVIDERS = {
"cpu": "CPUExecutionProvider",
"cuda": "CUDAExecutionProvider",
"rocm": "ROCMExecutionProvider",
}
def parse_arguments(argv=None):
parser = argparse.ArgumentParser()
conversion_args = parser.add_argument_group("Conversion Process Args")
optional_inputs = parser.add_argument_group("Optional Inputs (for WhisperBeamSearch op)")
optional_outputs = parser.add_argument_group("Optional Outputs (for WhisperBeamSearch op)")
quant_args = parser.add_argument_group("INT8 Quantization Args")
#################################
# Conversion options for Whisper
#################################
conversion_args.add_argument(
"-m",
"--model_name_or_path",
required=False,
default=PRETRAINED_WHISPER_MODELS[0],
type=str,
help="Model path, or pretrained model name in the list: " + ", ".join(PRETRAINED_WHISPER_MODELS),
)
conversion_args.add_argument(
"--model_impl",
required=False,
default="hf",
choices=["hf", "openai"],
type=str,
help="Select implementation for export of encoder and decoder subgraphs",
)
conversion_args.add_argument(
"--cache_dir",
required=False,
type=str,
default=os.path.join(".", "cache_models"),
help="Directory to cache pre-trained models",
)
conversion_args.add_argument(
"--output",
required=False,
type=str,
default=os.path.join(".", "onnx_models"),
help="Output directory",
)
conversion_args.add_argument(
"-o",
"--optimize_onnx",
required=False,
action="store_true",
help="Use optimizer.py to optimize onnx model",
)
conversion_args.set_defaults(optimize_onnx=False)
conversion_args.add_argument(
"--use_gpu",
required=False,
action="store_true",
help="Use GPU for model inference",
)
conversion_args.set_defaults(use_gpu=False)
conversion_args.add_argument(
"-p",
"--precision",
required=False,
type=Precision,
default=Precision.FLOAT32,
choices=[Precision.FLOAT32, Precision.FLOAT16, Precision.INT8],
help="Precision of model to run. fp32 for full precision, fp16 for half precision, int8 for quantization",
)
conversion_args.add_argument(
"--use_int64_inputs",
required=False,
action="store_true",
help="Use int64 instead of int32 for input_ids and attention_mask.",
)
conversion_args.set_defaults(use_int64_inputs=False)
conversion_args.add_argument(
"-r",
"--provider",
required=False,
type=str,
default="cpu",
choices=list(PROVIDERS.keys()),
help="Provider to benchmark. Default is CPUExecutionProvider.",
)
conversion_args.add_argument(
"--verbose",
required=False,
action="store_true",
help="Enable verbose logging",
)
conversion_args.set_defaults(verbose=False)
conversion_args.add_argument(
"-e",
"--use_external_data_format",
required=False,
action="store_true",
help="Save weights in external file. Necessary for 'small', 'medium', and 'large' models. Optional for 'tiny' and 'base' models.",
)
conversion_args.set_defaults(use_external_data_format=False)
conversion_args.add_argument(
"-w",
"--overwrite",
required=False,
action="store_true",
help="Overwrite existing ONNX model",
)
conversion_args.set_defaults(overwrite=False)
conversion_args.add_argument(
"--separate_encoder_and_decoder_init",
required=False,
action="store_true",
help="Do not merge encoder and decoder init to initialize past KV caches. Output 3 instead of 2 ONNX models.",
)
conversion_args.set_defaults(separate_encoder_and_decoder_init=False)
conversion_args.add_argument(
"--no_beam_search_op",
required=False,
action="store_true",
help="Do not produce model with WhisperBeamSearch op, which chains encdecinit and decoder models into one op.",
)
conversion_args.set_defaults(no_beam_search_op=False)
conversion_args.add_argument(
"--use_decoder_masked_mha",
required=False,
action="store_true",
help="Use DecoderMaskedMultiHeadAttention kernel for improved performance. This is currently an experimental feature.",
)
conversion_args.set_defaults(use_decoder_masked_mha=False)
#############################################################
# Optional inputs for Whisper
# (listed below in the order that WhisperBeamSearch expects)
#############################################################
optional_inputs.add_argument(
"-v",
"--use_vocab_mask",
required=False,
action="store_true",
help="Use vocab_mask as an extra graph input to enable specific logits processing",
)
optional_inputs.set_defaults(use_vocab_mask=False)
optional_inputs.add_argument(
"-u",
"--use_prefix_vocab_mask",
required=False,
action="store_true",
help="Use prefix_vocab_mask as an extra graph input to enable specific logits processing",
)
optional_inputs.set_defaults(use_prefix_vocab_mask=False)
optional_inputs.add_argument(
"-f",
"--use_forced_decoder_ids",
required=False,
action="store_true",
help="Use decoder_input_ids as an extra graph input to the beam search op",
)
optional_inputs.set_defaults(use_forced_decoder_ids=False)
optional_inputs.add_argument(
"-l",
"--use_logits_processor",
required=False,
action="store_true",
help="Use logits_processor as an extra graph input to enable specific logits processing",
)
optional_inputs.set_defaults(use_specific_logits_processor=False)
optional_inputs.add_argument(
"--collect_cross_qk",
required=False,
action="store_true",
help="Beam search model collect stacked cross QK.",
)
optional_inputs.set_defaults(collect_cross_qk=False)
optional_inputs.add_argument(
"--extra_decoding_ids",
required=False,
action="store_true",
help="Need extra starting decoding ids for some feature like cross qk. Default if false.",
)
optional_inputs.set_defaults(extra_decoding_ids=False)
optional_inputs.add_argument(
"-t",
"--use_temperature",
required=False,
action="store_true",
help="Use temperature as an extra graph input for the WhisperBeamSearch op",
)
optional_inputs.set_defaults(use_temperature=False)
optional_inputs.add_argument(
"--no_repeat_ngram_size",
type=int,
default=0,
help="default to 0",
)
#############################################################
# Optional outputs for Whisper
# (listed below in the order that WhisperBeamSearch expects)
#############################################################
optional_outputs.add_argument(
"--output_sequence_scores",
required=False,
action="store_true",
help="Beam search model output scores for each generated sequence.",
)
optional_outputs.set_defaults(output_sequence_scores=False)
optional_outputs.add_argument(
"--output_scores",
required=False,
action="store_true",
help="Beam search model output scores over vocab per generated token.",
)
optional_outputs.set_defaults(output_scores=False)
optional_outputs.add_argument(
"--output_cross_qk",
required=False,
action="store_true",
help="Beam search model output collected qk as output. Also hint collect_cross_qk",
)
optional_outputs.set_defaults(output_cross_qk=False)
optional_outputs.add_argument(
"--cross_qk_onnx_model",
required=False,
type=str,
default=None,
help="The model which consumes cross_qk outputs.",
)
optional_outputs.add_argument(
"--output_no_speech_probs",
required=False,
action="store_true",
help="Beam search model output no speech probs which is computed from the encoder/context-decoder graph.",
)
optional_outputs.set_defaults(output_no_speech_probs=False)
###################################
# Quantization options for Whisper
###################################
quant_args.add_argument(
"--quantize_embedding_layer",
required=False,
action="store_true",
help="Quantize MatMul, GEMM, and Gather.",
)
quant_args.set_defaults(quantize_embedding_layer=False)
quant_args.add_argument(
"--quantize_per_channel",
required=False,
action="store_true",
help="Quantize weights per each channel.",
)
quant_args.set_defaults(quantize_per_channel=False)
quant_args.add_argument(
"--quantize_reduce_range",
required=False,
action="store_true",
help="Quantize weights with 7 bits.",
)
quant_args.set_defaults(quantize_reduce_range=False)
args = parser.parse_args(argv)
# Collect cross QKs if either flag is enabled
args.collect_cross_qk = args.collect_cross_qk or args.output_cross_qk
# FP32 CPU can be supported here once the DMMHA CPU kernel bugs are fixed
args.use_decoder_masked_mha = args.use_decoder_masked_mha and args.provider == "cuda"
return args
def export_onnx_models(
model_name_or_path,
model_impl,
cache_dir,
output_dir,
use_gpu,
use_external_data_format,
optimize_onnx,
precision,
verbose,
use_forced_decoder_ids: bool = False,
merge_encoder_and_decoder_init: bool = True,
no_beam_search_op: bool = False,
use_decoder_masked_mha: bool = False,
output_qk: bool = False,
overwrite: bool = False,
use_int32_inputs: bool = True,
quantize_embedding_layer: bool = False,
quantize_per_channel: bool = False,
quantize_reduce_range: bool = False,
provider: str = "cpu",
):
device = torch.device("cuda" if use_gpu else "cpu")
models = WhisperHelper.load_model(
model_name_or_path,
model_impl,
cache_dir,
device,
torch.float16 if precision == Precision.FLOAT16 else torch.float32,
merge_encoder_and_decoder_init,
no_beam_search_op,
output_qk,
)
config = models["decoder"].config
if (not use_external_data_format) and (config.num_hidden_layers > 24):
logger.warning("You MUST pass `--use_external_data_format` because model size > 2GB")
raise Exception("Please pass `--use_external_data_format` for this model.")
output_paths = []
for name, model in models.items():
print(f"========> Handling {name} model......")
filename_suffix = "_" + name
onnx_path = WhisperHelper.get_onnx_path(
output_dir,
model_name_or_path,
suffix=filename_suffix,
new_folder=False,
)
# Export to ONNX
if overwrite or not os.path.exists(onnx_path):
logger.info(f"Exporting ONNX model to {onnx_path}")
WhisperHelper.export_onnx(
model,
onnx_path,
PROVIDERS[provider],
verbose,
use_external_data_format,
use_fp16_inputs=(precision == Precision.FLOAT16),
use_int32_inputs=use_int32_inputs,
use_encoder_hidden_states=(name == "decoder_init"),
use_kv_cache_inputs=(name == "decoder"),
)
else:
logger.info(f"Skip exporting: existing ONNX model {onnx_path}")
# Optimize ONNX model
if optimize_onnx or precision != Precision.FLOAT32:
output_path = WhisperHelper.get_onnx_path(
output_dir,
model_name_or_path,
suffix=filename_suffix + "_" + str(precision),
new_folder=False,
)
if overwrite or not os.path.exists(output_path):
if optimize_onnx:
logger.info(f"Optimizing model to {output_path}")
WhisperHelper.optimize_onnx(
onnx_path,
output_path,
precision == Precision.FLOAT16,
model.config.encoder_attention_heads,
model.config.d_model,
model.config.decoder_layers,
use_external_data_format,
use_gpu=use_gpu,
provider=provider,
is_decoder=(name == "decoder"),
no_beam_search_op=no_beam_search_op,
use_decoder_masked_mha=use_decoder_masked_mha,
output_qk=output_qk,
)
# Remove old ONNX model and old data file
if os.path.exists(onnx_path):
os.remove(onnx_path)
if os.path.exists(onnx_path + ".data"):
os.remove(onnx_path + ".data")
onnx_path = output_path
if isinstance(model, WhisperEncoder):
model.verify_onnx(
onnx_path,
PROVIDERS[provider],
use_fp16_inputs=(precision == Precision.FLOAT16),
)
else:
model.verify_onnx(
onnx_path,
PROVIDERS[provider],
use_fp16_inputs=(precision == Precision.FLOAT16),
use_int32_inputs=use_int32_inputs,
)
if precision == Precision.INT8:
quantization.quantize_dynamic(
onnx_path,
output_path,
op_types_to_quantize=(
["MatMul", "Gemm", "Gather"] if quantize_embedding_layer else ["MatMul", "Gemm"]
),
use_external_data_format=use_external_data_format,
per_channel=quantize_per_channel,
reduce_range=quantize_reduce_range,
extra_options={"MatMulConstBOnly": True},
)
else:
logger.info(f"Skip optimizing: existing ONNX model {onnx_path}")
else:
output_path = onnx_path
output_paths.append(output_path)
return output_paths
def main(argv=None):
args = parse_arguments(argv)
setup_logger(args.verbose)
logger.info(f"Arguments:{args}")
cache_dir = args.cache_dir
output_dir = args.output if not args.output.endswith(".onnx") else os.path.dirname(args.output)
prepare_environment(cache_dir, output_dir, args.use_gpu)
if args.precision == Precision.FLOAT16:
assert args.use_gpu, "fp16 requires --use_gpu"
output_paths = export_onnx_models(
args.model_name_or_path,
args.model_impl,
cache_dir,
output_dir,
args.use_gpu,
args.use_external_data_format,
args.optimize_onnx,
args.precision,
args.verbose,
args.use_forced_decoder_ids,
not args.separate_encoder_and_decoder_init,
args.no_beam_search_op,
args.use_decoder_masked_mha,
args.output_cross_qk,
args.overwrite,
not args.use_int64_inputs,
args.quantize_embedding_layer,
args.quantize_per_channel,
args.quantize_reduce_range,
args.provider,
)
max_diff = 0
if not args.no_beam_search_op:
logger.info("Chaining model ... :")
args.beam_model_output_dir = WhisperHelper.get_onnx_path(
output_dir,
args.model_name_or_path,
suffix="_beamsearch",
new_folder=False,
)
for path in output_paths:
if "encoder_decoder" in path or "encoder" in path:
args.encoder_path = path
elif "decoder" in path:
args.decoder_path = path
chain_model(args)
output_paths.append(args.beam_model_output_dir)
# Check chained model
ort_session = create_onnxruntime_session(
args.beam_model_output_dir,
use_gpu=args.use_gpu,
provider=args.provider,
)
device = torch.device("cuda" if args.use_gpu else "cpu")
# Wrap parity check in try-except to allow export to continue in case this produces an error
try:
with torch.no_grad():
# Verify batched decoding with prompts for OpenAI implementation
if args.model_impl == "openai" and args.use_forced_decoder_ids:
max_diff = WhisperHelper.verify_onnx(
args.model_name_or_path, cache_dir, ort_session, device, batch_size=2, prompt_mode=True
)
else:
max_diff = WhisperHelper.verify_onnx(args.model_name_or_path, cache_dir, ort_session, device)
if max_diff > 1e-4:
logger.warning("PyTorch and ONNX Runtime results are NOT close")
else:
logger.info("PyTorch and ONNX Runtime results are close")
except Exception as e:
logger.warning(
f"An error occurred while trying to verify parity between PyTorch and ONNX Runtime: {e}", exc_info=True
)
# Remove extra ONNX models saved in output directory
for _file in os.listdir(output_dir):
if "_beamsearch" not in _file and "_jump_times" not in _file:
path = os.path.join(output_dir, _file)
os.remove(path)
if path in output_paths:
output_paths.remove(path)
else:
# Create ancillary JSON files for ONNX Runtime GenAI and/or Hugging Face's Optimum
WhisperHelper.save_processing(
args.model_name_or_path,
args.provider,
args.separate_encoder_and_decoder_init,
args.use_decoder_masked_mha,
args.output_cross_qk,
next(iter(filter(lambda path: "encoder" in path, output_paths))),
next(iter(filter(lambda path: "decoder" in path, output_paths))),
output_dir,
cache_dir,
)
logger.info(f"Done! Outputs: {output_paths}")
return max_diff
if __name__ == "__main__":
main()

View file

@ -0,0 +1,331 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import logging
import os
import onnx
from benchmark_helper import Precision
from convert_generation import (
get_shared_initializers,
update_decoder_subgraph_output_cross_attention,
update_decoder_subgraph_share_buffer_and_use_decoder_masked_mha,
)
from onnx import TensorProto, helper
from transformers import WhisperConfig, WhisperTokenizer
logger = logging.getLogger(__name__)
def verify_inputs(beam_inputs, graph_inputs):
# Verify that ONNX graph's inputs match beam search op's inputs
beam_required_inputs = list(filter(lambda beam_input: beam_input, beam_inputs))
assert len(graph_inputs) == len(beam_required_inputs)
for graph_input, beam_input in zip(graph_inputs, beam_required_inputs, strict=False):
# Check if graph_input is in beam_input to handle beam_input names with the "_fp16" suffix
assert graph_input.name in beam_input
def clean_list(arr, remove_all_strings=True):
if remove_all_strings:
# Remove all empty strings in list
return list(filter(lambda elm: elm != "", arr))
# Remove empty strings at end of list
while len(arr) > 0:
if arr[-1] == "":
arr.pop()
else:
break
return arr
def chain_model(args):
# Load encoder/decoder and insert necessary (but unused) graph inputs expected by WhisperBeamSearch op
encoder_model = onnx.load_model(args.encoder_path, load_external_data=True)
encoder_model.graph.name = "encoderdecoderinit subgraph"
decoder_model = onnx.load_model(args.decoder_path, load_external_data=True)
decoder_model.graph.name = "decoder subgraph"
config = WhisperConfig.from_pretrained(args.model_name_or_path, cache_dir=args.cache_dir)
tokenizer = WhisperTokenizer.from_pretrained(args.model_name_or_path, cache_dir=args.cache_dir)
# Create inputs/outputs for WhisperBeamSearch op
temperature_name = "temperature_fp16" if args.precision == Precision.FLOAT16 else "temperature"
beam_inputs = [
"input_features_fp16" if args.precision == Precision.FLOAT16 else "input_features",
"max_length",
"min_length",
"num_beams",
"num_return_sequences",
"length_penalty_fp16" if args.precision == Precision.FLOAT16 else "length_penalty",
"repetition_penalty_fp16" if args.precision == Precision.FLOAT16 else "repetition_penalty",
"vocab_mask" if args.use_vocab_mask else "",
"prefix_vocab_mask" if args.use_prefix_vocab_mask else "",
"", # attention mask
"decoder_input_ids" if args.use_forced_decoder_ids else "",
"logits_processor" if args.use_logits_processor else "",
"cross_qk_layer_head" if args.collect_cross_qk else "",
"extra_decoding_ids" if args.extra_decoding_ids else "",
temperature_name if args.use_temperature else "",
]
sequence_scores_name = "sequence_scores_fp16" if args.precision == Precision.FLOAT16 else "sequence_scores"
scores_name = "scores_fp16" if args.precision == Precision.FLOAT16 else "scores"
beam_outputs = [
"sequences",
sequence_scores_name if args.output_sequence_scores else "",
scores_name if args.output_scores else "",
"cross_qk" if args.collect_cross_qk else "",
"no_speech_probs_beam" if args.output_no_speech_probs else "",
]
graph_nodes = []
if args.precision == Precision.FLOAT16:
input_features_cast_node = helper.make_node(
"Cast",
inputs=["input_features"],
outputs=["input_features_fp16"],
name="CastInputFeaturesToFp16",
to=TensorProto.FLOAT16,
)
len_pen_cast_node = helper.make_node(
"Cast",
inputs=["length_penalty"],
outputs=["length_penalty_fp16"],
name="CastLengthPenaltyToFp16",
to=TensorProto.FLOAT16,
)
rep_pen_cast_node = helper.make_node(
"Cast",
inputs=["repetition_penalty"],
outputs=["repetition_penalty_fp16"],
name="CastRepetitionPenaltyToFp16",
to=TensorProto.FLOAT16,
)
graph_nodes.extend([input_features_cast_node, len_pen_cast_node, rep_pen_cast_node])
if args.use_temperature:
temp_cast_node = helper.make_node(
"Cast",
inputs=["temperature"],
outputs=["temperature_fp16"],
name="temperature_to_fp16",
to=TensorProto.FLOAT16,
)
graph_nodes.append(temp_cast_node)
if args.output_sequence_scores:
output_sequence_scores_cast_node = helper.make_node(
"Cast",
inputs=["sequence_scores_fp16"],
outputs=["sequence_scores"],
name="CastOutputSequenceScoresToFp32",
to=TensorProto.FLOAT,
)
graph_nodes.append(output_sequence_scores_cast_node)
if args.output_scores:
output_scores_cast_node = helper.make_node(
"Cast",
inputs=["scores_fp16"],
outputs=["scores"],
name="CastScoresToFp32",
to=TensorProto.FLOAT,
)
graph_nodes.append(output_scores_cast_node)
# Create WhisperBeamSearch op
beam_search_attrs = [
helper.make_attribute("eos_token_id", config.eos_token_id),
helper.make_attribute("pad_token_id", config.pad_token_id),
helper.make_attribute(
"decoder_start_token_id", config.decoder_start_token_id
), # same as tokenizer.convert_tokens_to_ids(['<|startoftranscript|>'])[0]
helper.make_attribute("translate_token_id", tokenizer.convert_tokens_to_ids(["<|translate|>"])[0]),
helper.make_attribute("transcribe_token_id", tokenizer.convert_tokens_to_ids(["<|transcribe|>"])[0]),
helper.make_attribute("start_of_lm_token_id", tokenizer.convert_tokens_to_ids(["<|startoflm|>"])[0]),
(
helper.make_attribute("no_speech_token_id", tokenizer.convert_tokens_to_ids(["<|nospeech|>"])[0])
if args.output_no_speech_probs
else ""
),
helper.make_attribute("no_timestamps_token_id", tokenizer.convert_tokens_to_ids(["<|notimestamps|>"])[0]),
helper.make_attribute("beginning_timestamp_token_id", tokenizer.convert_tokens_to_ids(["<|0.00|>"])[0]),
helper.make_attribute("no_repeat_ngram_size", args.no_repeat_ngram_size),
helper.make_attribute("early_stopping", True),
helper.make_attribute("model_type", 2),
helper.make_attribute("decoder_output_cross_qk", 1) if args.collect_cross_qk else "",
]
node = helper.make_node(
"WhisperBeamSearch",
inputs=clean_list(beam_inputs, remove_all_strings=False),
outputs=clean_list(beam_outputs, remove_all_strings=False),
name="BeamSearch",
domain="com.microsoft",
)
node.attribute.extend(clean_list(beam_search_attrs, remove_all_strings=True))
# Graph inputs
input_features = helper.make_tensor_value_info(
"input_features", TensorProto.FLOAT, ["batch_size", "feature_size", "sequence_length"]
)
max_length = helper.make_tensor_value_info("max_length", TensorProto.INT32, [1])
min_length = helper.make_tensor_value_info("min_length", TensorProto.INT32, [1])
num_beams = helper.make_tensor_value_info("num_beams", TensorProto.INT32, [1])
num_return_sequences = helper.make_tensor_value_info("num_return_sequences", TensorProto.INT32, [1])
length_penalty = helper.make_tensor_value_info("length_penalty", TensorProto.FLOAT, [1])
repetition_penalty = helper.make_tensor_value_info("repetition_penalty", TensorProto.FLOAT, [1])
vocab_mask = helper.make_tensor_value_info("vocab_mask", TensorProto.INT32, [config.vocab_size])
prefix_vocab_mask = helper.make_tensor_value_info(
"prefix_vocab_mask", TensorProto.INT32, ["batch_size", config.vocab_size]
)
decoder_input_ids = helper.make_tensor_value_info(
"decoder_input_ids", TensorProto.INT32, ["batch_size", "initial_sequence_length"]
)
logits_processor = helper.make_tensor_value_info("logits_processor", TensorProto.INT32, [1])
cross_qk_layer_head = helper.make_tensor_value_info("cross_qk_layer_head", TensorProto.INT32, ["num_layer_head", 2])
extra_decoding_ids = helper.make_tensor_value_info(
"extra_decoding_ids", TensorProto.INT32, ["batch_size", "extra_decoding_ids_len"]
)
temperature = helper.make_tensor_value_info("temperature", TensorProto.FLOAT, [1])
graph_inputs = clean_list(
[
input_features,
max_length,
min_length,
num_beams,
num_return_sequences,
length_penalty,
repetition_penalty,
vocab_mask if args.use_vocab_mask else "",
prefix_vocab_mask if args.use_prefix_vocab_mask else "",
decoder_input_ids if args.use_forced_decoder_ids else "",
logits_processor if args.use_logits_processor else "",
cross_qk_layer_head if args.collect_cross_qk else "",
extra_decoding_ids if args.extra_decoding_ids else "",
temperature if args.use_temperature else "",
]
)
# Graph outputs
sequences = helper.make_tensor_value_info(
"sequences", TensorProto.INT32, ["batch_size", "num_return_sequences", "max_length"]
)
sequence_scores = helper.make_tensor_value_info("sequence_scores", TensorProto.FLOAT, ["batch_size"])
scores = helper.make_tensor_value_info("scores", TensorProto.FLOAT, ["batch_size"])
cross_qk = helper.make_tensor_value_info(
"cross_qk",
TensorProto.FLOAT,
["batch_size", "num_return_sequences", "num_layer_head_cross_qk", "max_length", "frames"],
)
no_speech_probs = helper.make_tensor_value_info("no_speech_probs", TensorProto.FLOAT, ["batch_size"])
graph_outputs = clean_list(
[
sequences,
sequence_scores if args.output_sequence_scores else "",
scores if args.output_scores else "",
cross_qk if args.output_cross_qk or (not args.cross_qk_onnx_model and args.collect_cross_qk) else "",
no_speech_probs if args.output_no_speech_probs else "",
]
)
# Replace MultiHeadAttention with DecoderMaskedMultiHeadAttention for CUDA EP inference
if hasattr(args, "use_gpu") and args.use_gpu:
if update_decoder_subgraph_share_buffer_and_use_decoder_masked_mha(decoder_model.graph):
logger.info("Updated whisper decoder subgraph to use DecoderMaskedMultiHeadAttention successfully!")
else:
logger.warning("DecoderMaskedMultiHeadAttention could not be applied to whisper decoder subgraph")
if hasattr(args, "collect_cross_qk") and args.collect_cross_qk:
update_decoder_subgraph_output_cross_attention(decoder_model.graph)
# Initializers/opsets
# Delete shared data between decoder/encoder and move to larger graph initializers
initializers = get_shared_initializers(encoder_model, decoder_model)
node.attribute.extend(
[
helper.make_attribute("decoder", decoder_model.graph),
helper.make_attribute("encoder", encoder_model.graph),
]
)
opset_import = [helper.make_opsetid(domain="com.microsoft", version=1), helper.make_opsetid(domain="", version=17)]
graph_nodes.append(node)
if args.output_no_speech_probs:
prob_cast_node = helper.make_node(
"Cast",
inputs=["no_speech_probs_beam"],
outputs=["no_speech_probs"],
name="no_speech_probs_cast_to_fp32",
to=TensorProto.FLOAT,
)
graph_nodes.append(prob_cast_node)
# Make graph with WhisperBeamSearch op
beam_graph = helper.make_graph(
graph_nodes,
name="WhisperBeamSearch Graph",
inputs=graph_inputs,
outputs=graph_outputs,
initializer=initializers,
)
beam_graph_input_names = [gi.name for gi in graph_inputs]
beam_graph_output_names = [go.name for go in graph_outputs]
if args.cross_qk_onnx_model:
post_qk_model = onnx.load_model(args.cross_qk_onnx_model, load_external_data=True)
post_qk_graph = post_qk_model.graph
beam_graph.initializer.extend(post_qk_graph.initializer)
beam_graph.node.extend(post_qk_graph.node)
# If tensor from cross_qk_onnx_model has same name as tensor in beamsearch graph, treat them as same tensor.
# User should notice this rule when provide cross_qk_onnx_model to append to the beamsearch node.
for pgi in post_qk_graph.input:
if (
(pgi.name not in beam_graph_input_names)
and (pgi.name not in beam_graph_output_names)
and (pgi.name != "cross_qk")
):
beam_graph.input.extend([pgi])
beam_graph.output.extend(post_qk_graph.output)
# Verify graph's inputs match beam search's inputs
verify_inputs(beam_inputs, graph_inputs)
assert decoder_model.ir_version == encoder_model.ir_version
logger.info(f"Using IR version {decoder_model.ir_version} for chained model")
# Set IR version of chained model to IR version of subgraphs in order to generate a working E2E model
beam_model = helper.make_model_gen_version(
beam_graph,
producer_name="onnxruntime.transformers",
opset_imports=opset_import,
ir_version=decoder_model.ir_version,
)
# Save WhisperBeamSearch graph and external data
if os.path.isfile(args.beam_model_output_dir):
logger.info(f"Overwriting {args.beam_model_output_dir} and {args.beam_model_output_dir + '.data'}")
if os.path.exists(args.beam_model_output_dir):
os.remove(args.beam_model_output_dir)
if os.path.exists(args.beam_model_output_dir + ".data"):
os.remove(args.beam_model_output_dir + ".data")
onnx.save(
beam_model,
args.beam_model_output_dir,
save_as_external_data=args.use_external_data_format,
all_tensors_to_one_file=True,
convert_attribute=True,
location=f"{os.path.basename(args.beam_model_output_dir)}.data",
)
try:
onnx.checker.check_model(args.beam_model_output_dir, full_check=True)
except Exception as e:
logger.error(f"An error occurred while running the ONNX checker: {e}", exc_info=True) # noqa: G201

View file

@ -0,0 +1,464 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import logging
import os
import tempfile
from itertools import chain
from pathlib import Path
import numpy as np
import onnx
import torch
from float16 import convert_float_to_float16
from google.protobuf.internal.containers import RepeatedCompositeFieldContainer
from onnx import ModelProto, ValueInfoProto
from onnx_model import OnnxModel
from past_helper import PastKeyValuesHelper
from transformers import WhisperConfig
from whisper_inputs import (
convert_inputs_for_ort,
get_model_dynamic_axes,
get_sample_decoder_inputs,
group_past_key_values,
)
from onnxruntime import InferenceSession
logger = logging.getLogger(__name__)
class WhisperDecoder(torch.nn.Module):
"""A Whisper decoder with optional past key values"""
def __init__(self, config: WhisperConfig, model: torch.nn.Module, model_impl: str, no_beam_search_op: bool = False):
super().__init__()
self.config = config
self.device = model.device
self.model_impl = model_impl
self.no_beam_search_op = no_beam_search_op
self.decoder = None if model_impl == "openai" else model.model.decoder
self.proj_out = None if model_impl == "openai" else model.proj_out
self.model = model if model_impl == "openai" else None
self.max_source_positions = self.config.max_source_positions
self.num_heads = self.config.decoder_attention_heads
self.head_size = self.config.d_model // self.num_heads
def hf_forward(
self,
decoder_input_ids: torch.Tensor,
encoder_hidden_states: torch.Tensor | None = None,
past_key_values: list[tuple[torch.Tensor]] | None = None,
):
outputs = self.decoder(
encoder_hidden_states=encoder_hidden_states,
input_ids=decoder_input_ids,
past_key_values=past_key_values,
use_cache=True,
)
logits = self.proj_out(outputs.last_hidden_state)
present_key_values = outputs.past_key_values
if past_key_values is None:
# Return present_self_* and present_cross_* for decoder-init
return logits, present_key_values
# Before: (past_key_self_0, past_value_self_0, past_key_cross_0, past_value_cross_0),
# (past_key_self_1, past_value_self_1, past_key_cross_1, past_value_cross_1),
# After: (past_key_self_0, past_value_self_0, past_key_self_1, past_value_self_1), ...,
# (past_key_cross_0, past_value_cross_0, past_key_cross_1, past_value_cross_1), ...
present_self, present_cross = PastKeyValuesHelper.group_by_self_and_cross(present_key_values)
# Return present_self_* for decoder-with-past since past_cross_* and present_cross_* are identical
return logits, present_self
def oai_forward(
self,
decoder_input_ids: torch.Tensor,
encoder_hidden_states: torch.Tensor | None = None,
past_key_values: list[tuple[torch.Tensor]] | None = None,
):
past_kv_cache = {}
if past_key_values is not None:
# Convert past KV caches (BxNxSxH --> BxSxNxH --> BxSxD) for OpenAI's forward pass
self_attn_kv_caches, cross_attn_kv_caches = group_past_key_values(past_key_values)
self_attn_kv_caches = [past_kv.transpose(1, 2) for past_kv in self_attn_kv_caches]
self_attn_kv_caches = [past_kv.reshape((*past_kv.shape[:2], -1)) for past_kv in self_attn_kv_caches]
cross_attn_kv_caches = [past_kv.transpose(1, 2) for past_kv in cross_attn_kv_caches]
cross_attn_kv_caches = [past_kv.reshape((*past_kv.shape[:2], -1)) for past_kv in cross_attn_kv_caches]
for idx, block in enumerate(self.model.decoder.blocks):
past_kv_cache[block.attn.key] = self_attn_kv_caches[2 * idx]
past_kv_cache[block.attn.value] = self_attn_kv_caches[2 * idx + 1]
past_kv_cache[block.cross_attn.key] = cross_attn_kv_caches[2 * idx]
past_kv_cache[block.cross_attn.value] = cross_attn_kv_caches[2 * idx + 1]
# Install OpenAI's hooks on the forward pass of each nn.Linear for key and value
# since the hooks will capture the output of the key and value MatMuls, which
# represent the current keys and values.
#
# For OpenAI's forward pass, the hook function will also perform the concat
# operation (past_kv + curr_kv --> pres_kv) if needed. However, the ONNX model
# will not contain this concat operation because the present KV caches aren't
# returned by OpenAI's forward pass.
kv_cache, hooks = self.model.install_kv_cache_hooks()
# Run forward pass
# NOTE: There is a bug with openai-whisper==20240930 with the introduction of SDPA.
# In the Whisper codebase, the following line
#
# is_causal = mask is not None and n_ctx > 1
#
# has been added where `mask` is a torch tensor. The right-hand side evaluates to `tensor(True/False)`
# but `is_causal` only accepts the boolean value. The fix is to apply `.item()` after the right-hand
# side has been evaluated. In other words, the line should be
#
# is_causal = (mask is not None and n_ctx > 1).item()
#
# instead.
logits = self.model.decoder(x=decoder_input_ids, xa=encoder_hidden_states, kv_cache=past_kv_cache)
# Re-do concat operation on self attention KV caches for ONNX export (if past self attention KV caches exist)
if past_key_values is not None:
for block in self.model.decoder.blocks:
kv_cache[block.attn.key] = torch.cat(
[past_kv_cache[block.attn.key], kv_cache[block.attn.key]], dim=1
).detach()
kv_cache[block.attn.value] = torch.cat(
[past_kv_cache[block.attn.value], kv_cache[block.attn.value]], dim=1
).detach()
present_self, present_cross = [], []
for block in self.model.decoder.blocks:
# Group self and cross values
present_self.append(kv_cache[block.attn.key])
present_self.append(kv_cache[block.attn.value])
if past_key_values is None:
# Return present_self_* and present_cross_* for decoder-init
present_cross.append(kv_cache[block.cross_attn.key])
present_cross.append(kv_cache[block.cross_attn.value])
# Convert present KV caches (BxSxD --> BxSxNxH --> BxNxSxH) after OpenAI's forward pass
present_self = [
present_kv.reshape((*present_kv.shape[:2], -1, self.head_size)).transpose(1, 2)
for present_kv in present_self
]
present_cross = [
present_kv.reshape((*present_kv.shape[:2], -1, self.head_size)).transpose(1, 2)
for present_kv in present_cross
]
# Remove OpenAI's hooks since they can persist after this function completes
for hook in hooks:
hook.remove()
if past_key_values is None:
# Return present_self_* and present_cross_* for decoder-init
present_key_values = PastKeyValuesHelper.group_by_layer(
present_self + present_cross, len(present_self) // 2
)
return logits, present_key_values
# Return present_self_* for decoder-with-past since past_cross_* and present_cross_* are identical
return logits, present_self
def forward(
self,
decoder_input_ids: torch.Tensor,
encoder_hidden_states: torch.Tensor | None = None,
past_key_values: list[tuple[torch.Tensor]] | None = None,
):
if self.model_impl == "openai":
return self.oai_forward(decoder_input_ids, encoder_hidden_states, past_key_values)
return self.hf_forward(decoder_input_ids, encoder_hidden_states, past_key_values)
def input_names(self):
if self.first_pass:
input_names = ["input_ids", "encoder_hidden_states"]
else:
input_names = [
"input_ids",
"encoder_hidden_states",
*list(
chain.from_iterable(
(f"past_key_self_{i}", f"past_value_self_{i}", f"past_key_cross_{i}", f"past_value_cross_{i}")
for i in range(self.config.decoder_layers)
)
),
]
return input_names
def output_names(self):
if self.first_pass:
output_names = [
"logits",
*list(
chain.from_iterable(
(
f"present_key_self_{i}",
f"present_value_self_{i}",
f"present_key_cross_{i}",
f"present_value_cross_{i}",
)
for i in range(self.config.decoder_layers)
)
),
]
else:
output_names = [
"logits",
*list(
chain.from_iterable(
(f"present_key_self_{i}", f"present_value_self_{i}") for i in range(self.config.decoder_layers)
)
),
]
return output_names
def dynamic_axes(self, input_names, output_names):
dynamic_axes = get_model_dynamic_axes(self.config, input_names, output_names)
if "input_ids" in dynamic_axes and not self.no_beam_search_op:
# Set dynamic axes for `input_ids` when using beam search op to {0: "batch_size"} only
del dynamic_axes["input_ids"][1]
return dynamic_axes
def inputs(self, use_fp16_inputs: bool, use_int32_inputs: bool, return_dict: bool = False):
inputs = get_sample_decoder_inputs(
self.config,
self.device,
batch_size=2,
past_sequence_length=(0 if self.first_pass else 6),
sequence_length=(6 if self.first_pass else 1),
use_fp16=use_fp16_inputs,
use_int32=use_int32_inputs,
)
if return_dict:
if self.first_pass:
del inputs["past_key_values"]
return inputs
if self.first_pass:
return (
inputs["decoder_input_ids"],
inputs["encoder_hidden_states"],
)
return (
inputs["decoder_input_ids"],
inputs["encoder_hidden_states"],
inputs["past_key_values"],
)
def fix_key_value_cache_dims(self, io: ValueInfoProto, is_cross: bool = False, is_output: bool = False):
# Shape should be (batch_size, num_heads, sequence_length, head_size) for self attention KV caches
# and (batch_size, num_heads, num_frames // 2, head_size) for cross attention KV caches
num_heads = io.type.tensor_type.shape.dim[1]
if "_dim_" in num_heads.dim_param:
num_heads.Clear()
num_heads.dim_value = self.num_heads
sequence_length = io.type.tensor_type.shape.dim[2]
if "_dim_" in sequence_length.dim_param:
sequence_length.Clear()
if is_cross:
sequence_length.dim_value = self.max_source_positions
else:
sequence_length.dim_param = "total_sequence_length" if is_output else "past_sequence_length"
head_size = io.type.tensor_type.shape.dim[3]
if "_dim_" in head_size.dim_param:
head_size.Clear()
head_size.dim_value = self.head_size
return io
def fix_io(self, io_list: RepeatedCompositeFieldContainer, is_output: bool = False):
# Fix order of inputs/outputs and each dim_value of input/output
reordered_io = []
self_attn_kv_caches = []
cross_attn_kv_caches = []
for io in io_list:
if "past" not in io.name and "present" not in io.name:
reordered_io.append(io)
elif "self" in io.name:
# Self attention KV caches
new_io = self.fix_key_value_cache_dims(io, is_cross=False, is_output=is_output)
if self.no_beam_search_op:
reordered_io.append(new_io)
else:
self_attn_kv_caches.append(new_io)
else:
# Cross attention KV caches
new_io = self.fix_key_value_cache_dims(io, is_cross=True, is_output=is_output)
if self.no_beam_search_op:
reordered_io.append(new_io)
else:
cross_attn_kv_caches.append(new_io)
if not self.no_beam_search_op:
reordered_io += self_attn_kv_caches + cross_attn_kv_caches
return reordered_io
def fix_inputs_and_outputs(self, model: ModelProto):
# ONNX exporter might mark dimensions like 'Transposepresent_value_self_1_dim_2' in shape inference.
# We now change the dim_values to the correct one.
reordered_inputs = self.fix_io(model.graph.input, is_output=False)
while len(model.graph.input) > 0:
model.graph.input.pop()
model.graph.input.extend(reordered_inputs)
reordered_outputs = self.fix_io(model.graph.output, is_output=True)
while len(model.graph.output) > 0:
model.graph.output.pop()
model.graph.output.extend(reordered_outputs)
return model
def fix_layernorm_weights(self, model: ModelProto, use_fp16_inputs: bool):
if self.model_impl == "openai" and use_fp16_inputs:
# Cast ONNX model to float16 to ensure LayerNorm weights are converted from
# float32 to float16 since exported model already has float16 weights everywhere
# except for LayerNorm ops. This happens because OpenAI always upcasts to float32
# when computing LayerNorm.
#
# Reference:
# https://github.com/openai/whisper/blob/90db0de1896c23cbfaf0c58bc2d30665f709f170/whisper/model.py#L41
model = convert_float_to_float16(model)
return model
def export_onnx(
self,
onnx_model_path: str,
provider: str,
verbose: bool = True,
use_external_data_format: bool = False,
use_fp16_inputs: bool = False,
use_int32_inputs: bool = True,
use_encoder_hidden_states: bool = False,
use_kv_cache_inputs: bool = True,
):
"""Export decoder to ONNX
Args:
onnx_model_path (str): path to save ONNX model
provider (str): provider to use for verifying parity on ONNX model
verbose (bool, optional): print verbose information. Defaults to True.
use_external_data_format (bool, optional): use external data format or not. Defaults to False.
use_fp16_inputs (bool, optional): use float16 inputs for the KV caches. Defaults to False.
use_int32_inputs (bool, optional): use int32 inputs for the decoder_input_ids. Defaults to True.
use_encoder_hidden_states (bool, optional): use encoder_hidden_states as model input for decoder-init/decoder-without-past models. Defaults to False.
use_kv_cache_inputs (bool, optional): use KV caches as model inputs for decoder-with-past models. Defaults to True.
"""
# Shape of decoder's tensors:
# Required Inputs:
# decoder_input_ids: (batch_size, sequence_length)
# Optional Inputs:
# encoder_hidden_states (comes from encoder's outputs): (batch_size, num_frames // 2, hidden_size)
# past_{key/value}_self_* (past self attention KV caches): (batch_size, num_heads, past_sequence_length, head_size)
# past_{key/value}_cross_* (past cross attention KV caches): (batch_size, num_heads, num_frames // 2, head_size)
# Outputs:
# logits: (batch_size, sequence_length, vocab_size)
# present_{key/value}_self_* (present self attention KV caches): (batch_size, num_heads, past_sequence_length + sequence_length, head_size)
# present_{key/value}_cross_* (present cross attention KV caches): (batch_size, num_heads, num_frames // 2, head_size)
# For the first pass through the decoder (i.e. decoder-init/decoder-without-past)
self.first_pass = use_encoder_hidden_states and not use_kv_cache_inputs
# For subsequent passes through the decoder (i.e. decoder-with-past)
self.later_pass = not use_encoder_hidden_states and use_kv_cache_inputs
assert self.first_pass or self.later_pass, (
"Only one of `use_encoder_hidden_states` and `use_kv_cache_inputs` can be true at once."
)
inputs = self.inputs(use_fp16_inputs=use_fp16_inputs, use_int32_inputs=use_int32_inputs)
input_names = self.input_names()
output_names = self.output_names()
dynamic_axes = self.dynamic_axes(input_names, output_names)
Path(onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp_dir_name:
temp_onnx_model_path = os.path.join(tmp_dir_name, "decoder.onnx")
Path(temp_onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
out_path = temp_onnx_model_path if use_external_data_format else onnx_model_path
torch.onnx.export(
self,
args=inputs,
f=out_path,
export_params=True,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=17,
do_constant_folding=True,
verbose=verbose,
)
model = onnx.load_model(out_path, load_external_data=use_external_data_format)
model = self.fix_inputs_and_outputs(model)
model = self.fix_layernorm_weights(model, use_fp16_inputs)
OnnxModel.save(
model,
onnx_model_path,
save_as_external_data=use_external_data_format,
all_tensors_to_one_file=True,
)
self.verify_onnx(onnx_model_path, provider, use_fp16_inputs, use_int32_inputs)
def verify_onnx(
self,
onnx_model_path: str,
provider: str,
use_fp16_inputs: bool,
use_int32_inputs: bool,
):
"""Verify ONNX model outputs and PyTorch model outputs match
Args:
onnx_model_path (str): path to save ONNX model
provider (str): execution provider for ONNX model
use_fp16_inputs (bool, optional): use float16 inputs for the KV caches
use_int32_inputs (bool, optional): use int32 inputs for the decoder_input_ids
"""
# Shape of decoder's tensors:
# Required Inputs:
# decoder_input_ids: (batch_size, sequence_length)
# Optional Inputs:
# encoder_hidden_states (comes from encoder's outputs): (batch_size, num_frames // 2, hidden_size)
# past_{key/value}_self_* (past self attention KV caches): (batch_size, num_heads, past_sequence_length, head_size)
# past_{key/value}_cross_* (past cross attention KV caches): (batch_size, num_heads, num_frames // 2, head_size)
# Outputs:
# logits: (batch_size, sequence_length, vocab_size)
# present_{key/value}_self_* (present self attention KV caches): (batch_size, num_heads, past_sequence_length + sequence_length, head_size)
# present_{key/value}_cross_* (present cross attention KV caches): (batch_size, num_heads, num_frames // 2, head_size)
# Run PyTorch model
inputs = self.inputs(use_fp16_inputs=use_fp16_inputs, use_int32_inputs=use_int32_inputs, return_dict=True)
pt_outputs = []
if self.first_pass:
out = self.forward(**inputs)
pt_outputs.append(out[0].detach().cpu().numpy())
for present_key_value_layer in out[1]:
for present_key_value in present_key_value_layer:
pt_outputs.append(present_key_value.detach().cpu().numpy())
else:
out = self.forward(**inputs)
pt_outputs.append(out[0].detach().cpu().numpy())
for present_self_key_value in out[1]:
pt_outputs.append(present_self_key_value.detach().cpu().numpy())
# Run ONNX model
sess = InferenceSession(onnx_model_path, providers=[provider])
ort_outputs = sess.run(None, convert_inputs_for_ort(inputs, sess))
# Calculate output difference
try:
for i, output_name in enumerate(self.output_names()):
diff = np.abs(pt_outputs[i] - ort_outputs[i])
logger.warning(f"Comparing {output_name}...")
logger.warning(f"Max diff: {np.max(diff)}")
except: # noqa: E722
pass

View file

@ -0,0 +1,164 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import logging
import os
import tempfile
from pathlib import Path
import numpy as np
import onnx
import torch
from float16 import convert_float_to_float16
from onnx import ModelProto
from onnx_model import OnnxModel
from transformers import WhisperConfig
from whisper_inputs import get_model_dynamic_axes, get_sample_encoder_inputs
from onnxruntime import InferenceSession
logger = logging.getLogger(__name__)
class WhisperEncoder(torch.nn.Module):
"""Whisper encoder component"""
def __init__(self, config: WhisperConfig, model: torch.nn.Module, model_impl: str):
super().__init__()
self.config = config
self.device = model.device
self.model_impl = model_impl
self.encoder = model.encoder if model_impl == "openai" else model.model.encoder
def forward(self, audio_features: torch.Tensor):
outputs = self.encoder(audio_features)
return outputs if self.model_impl == "openai" else outputs.last_hidden_state
def input_names(self):
input_names = ["audio_features"]
return input_names
def output_names(self):
output_names = ["encoder_hidden_states"]
return output_names
def dynamic_axes(self, input_names, output_names):
dynamic_axes = get_model_dynamic_axes(self.config, input_names, output_names)
return dynamic_axes
def fix_layernorm_weights(self, model: ModelProto, use_fp16_inputs: bool):
if self.model_impl == "openai" and use_fp16_inputs:
# Cast ONNX model to float16 to ensure LayerNorm weights are converted from
# float32 to float16 since exported model already has float16 weights everywhere
# except for LayerNorm ops. This happens because OpenAI always upcasts to float32
# when computing LayerNorm.
#
# Reference:
# https://github.com/openai/whisper/blob/90db0de1896c23cbfaf0c58bc2d30665f709f170/whisper/model.py#L41
model = convert_float_to_float16(model)
return model
def export_onnx(
self,
onnx_model_path: str,
provider: str,
verbose: bool = True,
use_external_data_format: bool = False,
use_fp16_inputs: bool = False,
):
"""Export encoder to ONNX
Args:
onnx_model_path (str): path to save ONNX model
provider (str): provider to use for verifying parity on ONNX model
verbose (bool, optional): print verbose information. Defaults to True.
use_external_data_format (bool, optional): use external data format or not. Defaults to False.
use_fp16_inputs (bool, optional): use float16 inputs for the audio_features. Defaults to False.
"""
# Shape of encoder's tensors:
# Inputs:
# audio_features: (batch_size, num_mels, num_frames)
# Outputs:
# encoder_hidden_states: (batch_size, num_frames // 2, hidden_size)
inputs = get_sample_encoder_inputs(
self.config,
self.device,
batch_size=2,
use_fp16=use_fp16_inputs,
)
input_names = self.input_names()
output_names = self.output_names()
dynamic_axes = self.dynamic_axes(input_names, output_names)
Path(onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp_dir_name:
temp_onnx_model_path = os.path.join(tmp_dir_name, "encoder.onnx")
Path(temp_onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
out_path = temp_onnx_model_path if use_external_data_format else onnx_model_path
torch.onnx.export(
self,
args=(inputs["audio_features"]),
f=out_path,
export_params=True,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=17,
do_constant_folding=True,
verbose=verbose,
)
model = onnx.load_model(out_path, load_external_data=use_external_data_format)
model = self.fix_layernorm_weights(model, use_fp16_inputs)
OnnxModel.save(
model,
onnx_model_path,
save_as_external_data=use_external_data_format,
all_tensors_to_one_file=True,
)
self.verify_onnx(onnx_model_path, provider, use_fp16_inputs)
def verify_onnx(
self,
onnx_model_path: str,
provider: str,
use_fp16_inputs: bool,
):
"""Verify ONNX model outputs and PyTorch model outputs match
Args:
onnx_model_path (str): path to save ONNX model
provider (str): execution provider for ONNX model
use_fp16_inputs (bool, optional): use float16 inputs for the audio_features
"""
# Shape of encoder's tensors:
# Inputs:
# audio_features: (batch_size, num_mels, num_frames)
# Outputs:
# encoder_hidden_states: (batch_size, num_frames // 2, hidden_size)
inputs = get_sample_encoder_inputs(
self.config,
self.device,
batch_size=2,
use_fp16=use_fp16_inputs,
)
# Run PyTorch model
pt_outputs = self.forward(inputs["audio_features"]).detach().cpu().numpy()
# Run ONNX model
sess = InferenceSession(onnx_model_path, providers=[provider])
ort_outputs = sess.run(None, {"audio_features": inputs["audio_features"].detach().cpu().numpy()})[0]
# Calculate output difference
diff = np.abs(pt_outputs - ort_outputs)
logger.warning("Comparing encoder_hidden_states...")
logger.warning(f"Max diff: {np.max(diff)}")

View file

@ -0,0 +1,371 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import logging
import os
import tempfile
from itertools import chain
from pathlib import Path
import numpy as np
import onnx
import torch
from float16 import convert_float_to_float16
from onnx import ModelProto, ValueInfoProto
from onnx_model import OnnxModel
from transformers import WhisperConfig
from whisper_decoder import WhisperDecoder
from whisper_encoder import WhisperEncoder
from whisper_inputs import (
convert_inputs_for_ort,
get_model_dynamic_axes,
get_sample_encoder_decoder_init_inputs,
group_past_key_values,
)
from onnxruntime import InferenceSession
logger = logging.getLogger(__name__)
class WhisperEncoderDecoderInit(torch.nn.Module):
"""Whisper encoder component + first pass through Whisper decoder component to initialize KV caches"""
def __init__(self, config: WhisperConfig, model: torch.nn.Module, model_impl: str, no_beam_search_op: bool = False):
super().__init__()
self.config = config
self.device = model.device
self.model_impl = model_impl
self.no_beam_search_op = no_beam_search_op
self.encoder = WhisperEncoder(config, model, model_impl)
self.decoder = WhisperDecoder(config, model, model_impl, no_beam_search_op)
self.max_source_positions = self.config.max_source_positions
self.num_heads = self.config.decoder_attention_heads
self.head_size = self.config.d_model // self.num_heads
def hf_forward_for_beam_search_op(self, audio_features: torch.Tensor, decoder_input_ids: torch.Tensor):
encoder_hidden_states = self.encoder(audio_features)
logits, present_key_values = self.decoder(decoder_input_ids, encoder_hidden_states)
return logits, encoder_hidden_states, present_key_values
def hf_forward_for_no_beam_search_op(self, audio_features: torch.Tensor):
encoder_hidden_states = self.encoder(audio_features)
# Get cross attention KV caches and return them for this model
# We do this because these MatMuls are only run once before their outputs are being re-used in the decoder
present_cross_attention_key_value_caches = []
for layer in self.decoder.decoder.layers:
cross_attn_key_cache = (
layer.encoder_attn.k_proj(encoder_hidden_states)
.view(-1, self.max_source_positions, self.num_heads, self.head_size)
.transpose(1, 2)
)
cross_attn_value_cache = (
layer.encoder_attn.v_proj(encoder_hidden_states)
.view(-1, self.max_source_positions, self.num_heads, self.head_size)
.transpose(1, 2)
)
present_cross_attention_key_value_caches.append(cross_attn_key_cache)
present_cross_attention_key_value_caches.append(cross_attn_value_cache)
return encoder_hidden_states, present_cross_attention_key_value_caches
def oai_forward_for_beam_search_op(self, audio_features: torch.Tensor, decoder_input_ids: torch.Tensor):
encoder_hidden_states = self.encoder(audio_features)
logits, present_key_values = self.decoder(decoder_input_ids, encoder_hidden_states)
return logits, encoder_hidden_states, present_key_values
def oai_forward_for_no_beam_search_op(self, audio_features: torch.Tensor):
encoder_hidden_states = self.encoder(audio_features)
# Get cross attention KV caches and return them for this model
# We do this because these MatMuls are only run once before their outputs are being re-used in the decoder
present_cross_attention_key_value_caches = []
for block in self.decoder.model.decoder.blocks:
cross_attn_key_cache = (
block.cross_attn.key(encoder_hidden_states)
.view(-1, self.max_source_positions, self.num_heads, self.head_size)
.transpose(1, 2)
)
cross_attn_value_cache = (
block.cross_attn.value(encoder_hidden_states)
.view(-1, self.max_source_positions, self.num_heads, self.head_size)
.transpose(1, 2)
)
present_cross_attention_key_value_caches.append(cross_attn_key_cache)
present_cross_attention_key_value_caches.append(cross_attn_value_cache)
return encoder_hidden_states, present_cross_attention_key_value_caches
def forward(self, audio_features: torch.Tensor, decoder_input_ids: torch.Tensor | None = None):
if self.model_impl == "openai":
if self.no_beam_search_op:
return self.oai_forward_for_no_beam_search_op(audio_features)
return self.oai_forward_for_beam_search_op(audio_features, decoder_input_ids)
# Hugging Face implementation
if self.no_beam_search_op:
return self.hf_forward_for_no_beam_search_op(audio_features)
return self.hf_forward_for_beam_search_op(audio_features, decoder_input_ids)
def input_names(self):
if self.no_beam_search_op:
input_names = ["audio_features"]
else:
input_names = ["encoder_input_ids", "decoder_input_ids"]
return input_names
def output_names(self):
if self.no_beam_search_op:
output_names = [
"encoder_hidden_states",
*list(
chain.from_iterable(
(f"present_key_cross_{i}", f"present_value_cross_{i}")
for i in range(self.config.decoder_layers)
)
),
]
else:
output_names = [
"logits",
"encoder_hidden_states",
*list(
chain.from_iterable(
(
f"present_key_self_{i}",
f"present_value_self_{i}",
f"present_key_cross_{i}",
f"present_value_cross_{i}",
)
for i in range(self.config.decoder_layers)
)
),
]
return output_names
def dynamic_axes(self, input_names, output_names):
dynamic_axes = get_model_dynamic_axes(self.config, input_names, output_names)
return dynamic_axes
def inputs(self, use_fp16_inputs: bool, use_int32_inputs: bool, return_dict: bool = False):
inputs = get_sample_encoder_decoder_init_inputs(
self.config,
self.device,
batch_size=2,
decoder_sequence_length=6,
use_fp16=use_fp16_inputs,
use_int32=use_int32_inputs,
)
if return_dict:
if self.no_beam_search_op:
del inputs["decoder_input_ids"]
return inputs
if self.no_beam_search_op:
return (inputs["audio_features"],)
return (
inputs["audio_features"],
inputs["decoder_input_ids"],
)
def fix_key_value_cache_dims(self, output: ValueInfoProto, is_cross: bool = False):
# Shape should be (batch_size, num_heads, sequence_length, head_size) for self attention KV caches
# and (batch_size, num_heads, num_frames // 2, head_size) for cross attention KV caches
num_heads = output.type.tensor_type.shape.dim[1]
if "_dim_" in num_heads.dim_param:
num_heads.Clear()
num_heads.dim_value = self.num_heads
sequence_length = output.type.tensor_type.shape.dim[2]
if "_dim_" in sequence_length.dim_param:
sequence_length.Clear()
if is_cross:
sequence_length.dim_value = self.max_source_positions
else:
sequence_length.dim_param = "total_sequence_length"
head_size = output.type.tensor_type.shape.dim[3]
if "_dim_" in head_size.dim_param:
head_size.Clear()
head_size.dim_value = self.head_size
return output
def fix_outputs(self, model: ModelProto):
# ONNX exporter might mark dimensions like 'Transposepresent_value_self_1_dim_2' in shape inference.
# We now change the dim_values to the correct one.
reordered_outputs = []
self_attn_kv_caches = []
cross_attn_kv_caches = []
for output in model.graph.output:
if "present" not in output.name:
reordered_outputs.append(output)
elif "self" in output.name:
# Self attention KV caches
new_output = self.fix_key_value_cache_dims(output, is_cross=False)
if self.no_beam_search_op:
reordered_outputs.append(new_output)
else:
self_attn_kv_caches.append(new_output)
else:
# Cross attention KV caches
new_output = self.fix_key_value_cache_dims(output, is_cross=True)
if self.no_beam_search_op:
reordered_outputs.append(new_output)
else:
cross_attn_kv_caches.append(new_output)
if not self.no_beam_search_op:
reordered_outputs += self_attn_kv_caches + cross_attn_kv_caches
while len(model.graph.output) > 0:
model.graph.output.pop()
model.graph.output.extend(reordered_outputs)
return model
def fix_layernorm_weights(self, model: ModelProto, use_fp16_inputs: bool):
if self.model_impl == "openai" and use_fp16_inputs:
# Cast ONNX model to float16 to ensure LayerNorm weights are converted from
# float32 to float16 since exported model already has float16 weights everywhere
# except for LayerNorm ops. This happens because OpenAI always upcasts to float32
# when computing LayerNorm.
#
# Reference:
# https://github.com/openai/whisper/blob/90db0de1896c23cbfaf0c58bc2d30665f709f170/whisper/model.py#L41
model = convert_float_to_float16(model)
return model
def export_onnx(
self,
onnx_model_path: str,
provider: str,
verbose: bool = True,
use_external_data_format: bool = False,
use_fp16_inputs: bool = False,
use_int32_inputs: bool = True,
):
"""Export encoder-decoder-init to ONNX
Args:
onnx_model_path (str): path to save ONNX model
provider (str): provider to use for verifying parity on ONNX model
verbose (bool, optional): print verbose information. Defaults to True.
use_external_data_format (bool, optional): use external data format or not. Defaults to False.
use_fp16_inputs (bool, optional): use float16 inputs for the audio_features. Defaults to False.
use_int32_inputs (bool, optional): use int32 inputs for the decoder_input_ids. Defaults to True.
"""
# Shape of encoder's tensors:
# Inputs:
# audio_features: (batch_size, num_mels, num_frames)
# Outputs:
# encoder_hidden_states: (batch_size, num_frames // 2, hidden_size)
# Shape of decoder's tensors:
# Inputs:
# decoder_input_ids: (batch_size, sequence_length)
# encoder_hidden_states (comes from encoder's outputs): (batch_size, num_frames // 2, hidden_size)
# Outputs:
# logits: (batch_size, sequence_length, vocab_size)
# present_{key/value}_self_* (present self attention KV caches): (batch_size, num_heads, past_sequence_length + sequence_length, head_size)
# present_{key/value}_cross_* (present cross attention KV caches): (batch_size, num_heads, num_frames // 2, head_size)
inputs = self.inputs(use_fp16_inputs=use_fp16_inputs, use_int32_inputs=use_int32_inputs)
input_names = self.input_names()
output_names = self.output_names()
dynamic_axes = self.dynamic_axes(input_names, output_names)
Path(onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp_dir_name:
temp_onnx_model_path = os.path.join(tmp_dir_name, "encoder_decoder_init.onnx")
Path(temp_onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
out_path = temp_onnx_model_path if use_external_data_format else onnx_model_path
torch.onnx.export(
self,
args=inputs,
f=out_path,
export_params=True,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=17,
do_constant_folding=True,
verbose=verbose,
)
model = onnx.load_model(out_path, load_external_data=use_external_data_format)
model = self.fix_outputs(model)
model = self.fix_layernorm_weights(model, use_fp16_inputs)
OnnxModel.save(
model,
onnx_model_path,
save_as_external_data=use_external_data_format,
all_tensors_to_one_file=True,
)
self.verify_onnx(onnx_model_path, provider, use_fp16_inputs, use_int32_inputs)
def verify_onnx(
self,
onnx_model_path: str,
provider: str,
use_fp16_inputs: bool,
use_int32_inputs: bool,
):
"""Verify ONNX model outputs and PyTorch model outputs match
Args:
onnx_model_path (str): path to save ONNX model
provider (str): execution provider for ONNX model
use_fp16_inputs (bool, optional): use float16 inputs for the audio_features
use_int32_inputs (bool, optional): use int32 inputs for the decoder_input_ids
"""
# Shape of encoder's tensors:
# Inputs:
# audio_features: (batch_size, num_mels, num_frames)
# Outputs:
# encoder_hidden_states: (batch_size, num_frames // 2, hidden_size)
# Shape of decoder's tensors:
# Inputs:
# decoder_input_ids: (batch_size, sequence_length)
# encoder_hidden_states (comes from encoder's outputs): (batch_size, num_frames // 2, hidden_size)
# Outputs:
# logits: (batch_size, sequence_length, vocab_size)
# present_{key/value}_self_* (present self attention KV caches): (batch_size, num_heads, past_sequence_length + sequence_length, head_size)
# present_{key/value}_cross_* (present cross attention KV caches): (batch_size, num_heads, num_frames // 2, head_size)
inputs = self.inputs(use_fp16_inputs=use_fp16_inputs, use_int32_inputs=use_int32_inputs, return_dict=True)
# Run PyTorch model
pt_outputs = []
if self.no_beam_search_op:
out = self.forward(**inputs)
pt_outputs.append(out[0].detach().cpu().numpy())
for present_cross_attn_cache in out[1]:
pt_outputs.append(present_cross_attn_cache.detach().cpu().numpy())
else:
out = self.forward(**inputs)
pt_outputs.append(out[0].detach().cpu().numpy())
pt_outputs.append(out[1].detach().cpu().numpy())
(self_attn_kv_caches, cross_attn_kv_caches) = group_past_key_values(out[2])
pt_outputs.extend([self_attn_kv_cache.detach().cpu().numpy() for self_attn_kv_cache in self_attn_kv_caches])
pt_outputs.extend(
[cross_attn_kv_cache.detach().cpu().numpy() for cross_attn_kv_cache in cross_attn_kv_caches]
)
# Run ONNX model
sess = InferenceSession(onnx_model_path, providers=[provider])
ort_outputs = sess.run(None, convert_inputs_for_ort(inputs, sess))
# Calculate output difference
for i, output_name in enumerate(self.output_names()):
diff = np.abs(pt_outputs[i] - ort_outputs[i])
logger.warning(f"Comparing {output_name}...")
logger.warning(f"Max diff: {np.max(diff)}")

View file

@ -0,0 +1,380 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import logging
import numpy as np
import torch
from transformers import WhisperConfig
from onnxruntime import InferenceSession
logger = logging.getLogger(__name__)
# Create audio_features for encoder
# Shape is (batch_size, feature_size, sequence_length) = (batch_size, num_mel_filters, num_frames)
# where num_mel_filters is a model attribute and num_frames = (chunk_length * sample_rate) // hop_length.
#
# Hard-coded audio hyperparameters:
# SAMPLE_RATE = 16000
# N_FFT = 400
# HOP_LENGTH = 160
# CHUNK_LENGTH = 30 (i.e. 30-second chunk of audio)
# N_SAMPLES = CHUNK_LENGTH * SAMPLE_RATE = 30 * 16000 = 480000 (i.e. 480,000 samples in a 30-second chunk of audio)
# N_FRAMES = N_SAMPLES // HOP_LENGTH = 480000 // 160 = 3000 (i.e. 3000 frames in a mel spectrogram input)
#
# N_SAMPLES_PER_TOKEN = HOP_LENGTH * 2 = 160 * 2 = 320
# FRAMES_PER_TOKEN = SAMPLE_RATE // HOP_LENGTH = 16000 // 160 = 100 (i.e. 10 ms per audio frame)
# TOKENS_PER_SECOND = SAMPLE_RATE // N_SAMPLES_PER_TOKEN = 16000 // 320 = 50 (i.e. 20 ms per audio token)
def get_sample_audio_features(
config: WhisperConfig,
device: torch.device,
batch_size: int,
sequence_length: int = 3000,
use_fp16: bool = False,
):
torch_dtype = torch.float16 if use_fp16 else torch.float32
audio_features = torch.randn(batch_size, config.num_mel_bins, sequence_length, device=device, dtype=torch_dtype)
return audio_features
# Create input_ids for decoder
# Shape is (batch_size, sequence_length) where sequence_length is the initial decoder sequence length
def get_sample_decoder_input_ids(
config: WhisperConfig,
device: torch.device,
batch_size: int,
sequence_length: int,
use_int32: bool = True,
):
torch_dtype = torch.int32 if use_int32 else torch.int64
decoder_input_ids = torch.randint(
low=0, high=config.vocab_size, size=(batch_size, sequence_length), device=device, dtype=torch_dtype
)
return decoder_input_ids
# Create encoder_hidden_states for decoder-init
# Shape is (batch_size, num_frames // 2, hidden_size)
def get_sample_encoder_hidden_states(
config: WhisperConfig,
device: torch.device,
batch_size: int,
use_fp16: bool = False,
):
torch_dtype = torch.float16 if use_fp16 else torch.float32
encoder_hidden_states = torch.randn(
batch_size, config.max_source_positions, config.d_model, device=device, dtype=torch_dtype
)
return encoder_hidden_states
# Create past_key_values
# Self-attention KV caches are of shape (batch_size, num_heads, past_sequence_length, head_size)
# Cross-attention KV caches are of shape (batch_size, num_heads, num_frames // 2, head_size)
def get_sample_past_key_values(
config: WhisperConfig,
device: torch.device,
batch_size: int,
past_seq_len: int,
use_fp16: bool = False,
):
num_heads = config.decoder_attention_heads
head_size = config.d_model // num_heads
max_source_positions = (
config.max_source_positions
) # equal to num_frames // 2 = encoder's sequence_length // 2 = 3000 // 2 = 1500
torch_dtype = torch.float16 if use_fp16 else torch.float32
self_attention_kv_caches = [
(
torch.rand(batch_size, num_heads, past_seq_len, head_size, device=device, dtype=torch_dtype),
torch.rand(batch_size, num_heads, past_seq_len, head_size, device=device, dtype=torch_dtype),
)
for _ in range(config.decoder_layers)
]
cross_attention_kv_caches = [
(
torch.rand(batch_size, num_heads, max_source_positions, head_size, device=device, dtype=torch_dtype),
torch.rand(batch_size, num_heads, max_source_positions, head_size, device=device, dtype=torch_dtype),
)
for _ in range(config.decoder_layers)
]
return flatten_past_key_values(self_attention_kv_caches, cross_attention_kv_caches)
# Flatten KV caches into pairs-of-4 where each pair is defined as:
# (self_attn_key_cache, self_attn_value_cache, cross_attn_key_cache, cross_attn_value_cache)
def flatten_past_key_values(
self_attn_kv_caches: list[tuple[torch.Tensor, torch.Tensor]],
cross_attn_kv_caches: list[tuple[torch.Tensor, torch.Tensor]],
):
past_key_values = []
for (self_k_cache, self_v_cache), (cross_k_cache, cross_v_cache) in zip(
self_attn_kv_caches, cross_attn_kv_caches, strict=False
):
layer_kv_caches = (self_k_cache, self_v_cache, cross_k_cache, cross_v_cache)
past_key_values.append(layer_kv_caches)
return past_key_values
# Group KV caches into two 1D lists where one list contains the self attention KV caches and
# one list contains the cross attention KV caches
def group_past_key_values(
kv_caches: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]],
):
self_attn_kv_caches, cross_attn_kv_caches = [], []
for self_k_cache, self_v_cache, cross_k_cache, cross_v_cache in kv_caches:
self_attn_kv_caches.append(self_k_cache)
self_attn_kv_caches.append(self_v_cache)
cross_attn_kv_caches.append(cross_k_cache)
cross_attn_kv_caches.append(cross_v_cache)
return self_attn_kv_caches, cross_attn_kv_caches
# Create alignment heads for timestamps
# Shape is (num_alignment_heads, 2)
def get_sample_alignment_heads(
config: WhisperConfig,
device: torch.device,
num_alignment_heads: int = 6,
use_int32: bool = True,
):
torch_dtype = torch.int32 if use_int32 else torch.int64
alignment_heads = torch.ones((num_alignment_heads, 2), device=device, dtype=torch_dtype)
return alignment_heads
# Create length of start-of-transcription sequence for timestamps
# Shape is (1)
def get_sample_sot_sequence_length(
device: torch.device,
sot_sequence_length: int,
use_int32: bool = False,
):
torch_dtype = torch.int32 if use_int32 else torch.int64
sot_length = torch.tensor([sot_sequence_length], device=device, dtype=torch_dtype)
return sot_length
# Create segment length for timestamps
# Shape is (1)
def get_sample_segment_length(
device: torch.device,
segment_length: int,
use_int32: bool = False,
):
torch_dtype = torch.int32 if use_int32 else torch.int64
segment_size = torch.tensor([segment_length], device=device, dtype=torch_dtype)
return segment_size
# Create QKs for timestamps
# Shape is (batch_size, num_heads, sequence_length, num_frames // 2)
def get_sample_QKs( # noqa: N802
config: WhisperConfig,
device: torch.device,
batch_size: int,
sequence_length: int,
use_fp16: bool = False,
):
num_heads = config.decoder_attention_heads
torch_dtype = torch.float16 if use_fp16 else torch.float32
QKs = [ # noqa: N806
torch.rand(
batch_size, num_heads, sequence_length, config.max_source_positions, device=device, dtype=torch_dtype
)
for _ in range(config.decoder_layers)
]
return QKs
# Create inputs for encoder component of Whisper
def get_sample_encoder_inputs(
config: WhisperConfig,
device: torch.device,
batch_size: int,
sequence_length: int = 3000,
use_fp16: bool = False,
):
audio_features = get_sample_audio_features(config, device, batch_size, sequence_length, use_fp16)
return {"audio_features": audio_features}
# Create inputs for encoder component + first pass through decoder component of Whisper
def get_sample_encoder_decoder_init_inputs(
config: WhisperConfig,
device: torch.device,
batch_size: int,
decoder_sequence_length: int,
encoder_sequence_length: int = 3000,
use_fp16: bool = False,
use_int32: bool = True,
):
audio_features = get_sample_audio_features(config, device, batch_size, encoder_sequence_length, use_fp16)
decoder_input_ids = get_sample_decoder_input_ids(config, device, batch_size, decoder_sequence_length, use_int32)
return {"audio_features": audio_features, "decoder_input_ids": decoder_input_ids}
# Create inputs for decoder component of Whisper
# Inputs for first pass through the decoder (i.e. decoder-init): decoder_input_ids, encoder_hidden_states
# Inputs for subsequent passes through the decoder (i.e. decoder-with-past): decoder_input_ids, past_key_values
def get_sample_decoder_inputs(
config: WhisperConfig,
device: torch.device,
batch_size: int,
past_sequence_length: int,
sequence_length: int,
use_fp16: bool = False,
use_int32: bool = True,
):
decoder_input_ids = get_sample_decoder_input_ids(config, device, batch_size, sequence_length, use_int32)
encoder_hidden_states = get_sample_encoder_hidden_states(config, device, batch_size, use_fp16)
past_key_values = get_sample_past_key_values(config, device, batch_size, past_sequence_length, use_fp16)
return {
"decoder_input_ids": decoder_input_ids,
"encoder_hidden_states": encoder_hidden_states,
"past_key_values": past_key_values,
}
# Create inputs for timestamps component of Whisper
def get_sample_jump_times_inputs(
config: WhisperConfig,
device: torch.device,
batch_size: int,
sequence_length: int,
num_alignment_heads: int,
sot_sequence_length: int,
segment_length: int,
use_fp16: bool = False,
use_int32: bool = True,
):
alignment_heads = get_sample_alignment_heads(config, device, num_alignment_heads, use_int32)
# lengths need to be int64 because subsequent 'Slice' ops only take int64 inputs
sot_sequence_length = get_sample_sot_sequence_length(device, sot_sequence_length)
segment_length = get_sample_segment_length(device, segment_length)
QKs = get_sample_QKs(config, device, batch_size, sequence_length, use_fp16) # noqa: N806
return {
"alignment_heads": alignment_heads,
"sot_sequence_length": sot_sequence_length,
"segment_length": segment_length,
"QKs": QKs,
}
# Convert PyTorch inputs to ONNX Runtime inputs
def convert_inputs_for_ort(
inputs: dict,
model: InferenceSession,
):
self_attn_kv_caches, cross_attn_kv_caches = None, None
batch_size, num_heads, past_seq_len, head_size = 0, 0, 0, 0
num_beams, max_seq_len = 1, 448
if "past_key_values" in inputs:
(self_attn_kv_caches, cross_attn_kv_caches) = group_past_key_values(inputs["past_key_values"])
batch_size, num_heads, past_seq_len, head_size = self_attn_kv_caches[0].shape
ort_inputs = {}
model_inputs = list(map(lambda i: i.name, model.get_inputs())) # noqa: C417
use_buffer_sharing = "cache_indirection" in model_inputs
for name in model_inputs:
if name in {"audio_features", "encoder_input_ids"}:
# Encoder input
ort_inputs[name] = inputs["audio_features"].detach().cpu().numpy()
elif name == "encoder_hidden_states":
# Encoder output
ort_inputs[name] = inputs["encoder_hidden_states"].detach().cpu().numpy()
elif name in {"decoder_input_ids", "input_ids"}:
# Decoder input
ort_inputs[name] = inputs["decoder_input_ids"].detach().cpu().numpy()
elif "past_key_self" in name or "past_value_self" in name:
# Decoder input
orig_kv_cache = self_attn_kv_caches.pop(0).detach().cpu().numpy()
if use_buffer_sharing:
new_kv_cache = np.zeros((batch_size, num_heads, max_seq_len, head_size), dtype=orig_kv_cache.dtype)
new_kv_cache[:batch_size, :num_heads, :past_seq_len, :head_size] = orig_kv_cache
ort_inputs[name] = new_kv_cache
else:
ort_inputs[name] = orig_kv_cache
elif "past_key_cross" in name or "past_value_cross" in name:
# Decoder input
orig_kv_cache = cross_attn_kv_caches.pop(0).detach().cpu().numpy()
ort_inputs[name] = orig_kv_cache
elif name == "past_sequence_length":
# Decoder input
ort_inputs[name] = np.array([past_seq_len], dtype=np.int32)
elif name == "cache_indirection":
# Decoder input
ort_inputs[name] = np.zeros((batch_size, num_beams, max_seq_len), dtype=np.int32)
elif name == "alignment_heads":
# Jump times input
ort_inputs[name] = inputs["alignment_heads"].detach().cpu().numpy()
elif name == "sot_sequence_length":
# Jump times input
ort_inputs[name] = inputs["sot_sequence_length"].detach().cpu().numpy()
elif name == "segment_length":
# Jump times input
ort_inputs[name] = inputs["segment_length"].detach().cpu().numpy()
elif "cross_qk" in name:
# Jump times input
ort_inputs[name] = inputs["QKs"].pop(0).detach().cpu().numpy()
else:
raise ValueError(f"Unknown name not recognized: {name}")
return ort_inputs
# Get dynamic axes for all inputs and outputs to the model
def get_model_dynamic_axes(
config: WhisperConfig,
input_names: list[str],
output_names: list[str],
):
dynamic_axes = {}
for name in input_names + output_names:
if name in {"audio_features", "encoder_input_ids"}:
# shape is (batch_size, num_mels, num_frames)
dynamic_axes[name] = {0: "batch_size"}
elif name in {"input_ids", "decoder_input_ids"}:
# shape is (batch_size, sequence_length)
dynamic_axes[name] = {0: "batch_size", 1: "sequence_length"}
elif name == "alignment_heads":
# shape is (num_alignment_heads, 2)
dynamic_axes[name] = {0: "num_alignment_heads"}
elif name in {"sot_sequence_length", "segment_length"}:
# shape is (1)
pass
elif name == "logits":
# shape is (batch_size, sequence_length, vocab_size)
dynamic_axes[name] = {0: "batch_size", 1: "sequence_length"}
elif name == "encoder_hidden_states":
# shape is (batch_size, num_frames // 2, hidden_size)
dynamic_axes[name] = {0: "batch_size"}
elif "past_key_self" in name or "past_value_self" in name:
# shape is (batch_size, num_heads, past_sequence_length, head_size)
dynamic_axes[name] = {0: "batch_size", 2: "past_sequence_length"}
elif "present_key_self" in name or "present_value_self" in name:
# shape is (batch_size, num_heads, past_sequence_length + sequence_length, head_size),
# which is equal to (batch_size, num_heads, total_sequence_length, head_size)
dynamic_axes[name] = {0: "batch_size", 2: "total_sequence_length"}
elif (
"past_key_cross" in name
or "past_value_cross" in name
or "present_key_cross" in name
or "present_value_cross" in name
):
# shape is (batch_size, num_heads, num_frames // 2, head_size)
dynamic_axes[name] = {0: "batch_size"}
elif "cross_qk" in name:
# shape is (batch_size, num_heads, source_sequence_length, target_sequence_length)
dynamic_axes[name] = {0: "batch_size", 2: "sequence_length"}
elif "jump_times" in name:
# shape is (batch_size, max_length)
dynamic_axes[name] = {0: "batch_size", 1: "max_length"}
else:
raise Exception(f"Unknown input or output name found: {name}")
return dynamic_axes

View file

@ -0,0 +1,477 @@
# -------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# --------------------------------------------------------------------------
import logging
import os
import tempfile
import textwrap
from pathlib import Path
import numpy as np
import onnx
import torch
import torch.nn.functional as F
import torch.utils.cpp_extension
from onnx_model import OnnxModel
from transformers import WhisperConfig
from whisper_inputs import convert_inputs_for_ort, get_model_dynamic_axes, get_sample_jump_times_inputs
from onnxruntime import InferenceSession
from onnxruntime.tools import pytorch_export_contrib_ops
logger = logging.getLogger(__name__)
##################################################
# Functions that have to be outside of the class
# for torch.jit.script_if_tracing to work
##################################################
@torch.jit.script_if_tracing
def index_QKs(alignment_heads: torch.Tensor, QKs: list[torch.Tensor]): # noqa: N802
"""
Compute the following to get stacked QK tensor that has been indexed for the desired attention heads:
weights = torch.stack([QKs[_l][:, _h] for _l, _h in alignment_heads], dim=1)
"""
indexed_QKs = [] # noqa: N806
for pair in alignment_heads:
# Each QK is of shape (batch_size, num_heads, sequence_length, num_frames // 2)
# The `QKs[_l]` selects the right QK from the list of QKs
# The `QKs[_l][:, _h]` selects the right attention heads from the chosen QK. The `:` is to do this for the batch dim.
#
# PyTorch:
# QKs[_l] is of shape (batch_size, num_heads, sequence_length, num_frames // 2)
# QKs[_l][:, _h] is of shape (batch_size, sequence_length, num_frames // 2)
#
# ONNX:
# QKs[_l] is of shape (batch_size, num_heads, sequence_length, num_frames // 2)
# QKs[_l][:, _h] is of shape (batch_size, 1, sequence_length, num_frames // 2) because
# the `[:, _h]` operation maps to a Gather op and that op does not reduce dimensions
_l, _h = pair[0], pair[1]
indexed_QKs.append(QKs[_l][:, _h])
# PyTorch:
# torch.stack will return a tensor of shape (batch_size, num_alignment_heads, sequence_length, num_frames // 2).
#
# ONNX:
# torch.stack will return a tensor of shape (batch_size, num_alignment_heads, 1, sequence_length, num_frames // 2)
# because the Gather op does not reduce dimensions. To remove the unneeded dimension, torch.squeeze with a specified
# dim (dim = 2) is added. The torch.squeeze op with a specified dim only runs if the specified dim has a size of 1.
# Since the dim won't be of size 1 in the PyTorch tensor but it is of size 1 in the ONNX tensor, it will be a no-op
# in PyTorch and an op in ONNX. Thus, the Squeeze op will only affect the ONNX model.
weights = torch.stack(indexed_QKs, dim=1)
weights = torch.squeeze(weights, dim=2)
return weights
def jump_timings(text_indices, time_indices):
"""
Calculate jump times from text_indices and time_indices where
text_indices and time_indices are both 1d vectors
"""
TOKENS_PER_SECOND = 50.0 # noqa: N806
diff = text_indices[1:] - text_indices[:-1]
padding = torch.tensor([1], dtype=torch.int32)
jumps = torch.cat((padding, diff)).to(torch.bool)
jump_times = time_indices[jumps].to(torch.float) / TOKENS_PER_SECOND
return jump_times
def padded_jump_from_dtw(matrix_2d: torch.Tensor, max_length: torch.Tensor):
"""
Run Dynamic Time Warping (DTW) on batched tensor
"""
trace = torch.ops.onnxruntime.DynamicTimeWarping(matrix_2d)
text_indices = trace[0, :]
time_indices = trace[1, :]
jump_times = jump_timings(text_indices, time_indices)
return F.pad(jump_times, [0, int((max_length - jump_times.size(-1)).item())], mode="constant", value=-1.0)
@torch.jit.script_if_tracing
def batch_jump_times(matrix: torch.Tensor, max_decoded_length: torch.Tensor):
"""
Compute the following to calculate jump times for all batches:
batched_jump_times = torch.stack([self.padded_jump_from_dtw(matrix[b], max_decoded_length) for b in range(matrix.size(0))])
"""
list_of_jump_times = []
for b in range(matrix.size(0)):
jump_times = padded_jump_from_dtw(matrix[b], max_decoded_length)
list_of_jump_times.append(jump_times)
batched_jump_times = torch.stack(list_of_jump_times)
return batched_jump_times
class WhisperJumpTimes(torch.nn.Module):
"""Whisper jump times component"""
def __init__(self, config: WhisperConfig, device: torch.device, cache_dir: str | os.PathLike):
super().__init__()
self.config = config
self.device = device
self.cache_dir = cache_dir
self.filter_width = 7
self.qk_scale = 1.0
def median_filter(self, weights: torch.Tensor):
"""
Apply a median filter of width `filter_width` along the last dimension of `weights`
"""
pad_width = self.filter_width // 2
x = F.pad(weights, (pad_width, pad_width, 0, 0), mode="reflect")
x_unfolded = torch.ops.onnxruntime.UnfoldTensor(x, -1, self.filter_width, 1)
result = torch.select(x_unfolded.sort()[0], dim=-1, index=pad_width)
return result
def forward(
self,
alignment_heads: torch.Tensor,
sot_sequence_length: torch.Tensor,
segment_length: torch.Tensor,
QKs: list[torch.Tensor],
):
# Get stacked QKs tensor
weights = index_QKs(alignment_heads, QKs)
weights = weights[:, :, : segment_length // 2]
weights = weights.to(torch.float32)
weights = (weights * self.qk_scale).softmax(dim=-1)
std, mean = torch.std_mean(weights, dim=-2, keepdim=True, unbiased=False)
weights = (weights - mean) / std
weights = self.median_filter(weights)
matrix = torch.mean(weights, 1)
matrix = -matrix[:, sot_sequence_length:-1]
max_decoded_length = torch.tensor([matrix.size(1)], dtype=torch.int64)
batched_jump_times = batch_jump_times(matrix, max_decoded_length)
return batched_jump_times
def input_names(self):
input_names = [
"alignment_heads",
"sot_sequence_length",
"segment_length",
*[f"cross_qk_{i}" for i in range(self.config.decoder_layers)],
]
return input_names
def output_names(self):
output_names = ["jump_times"]
return output_names
def inputs(self, use_fp16_inputs: bool, use_int32_inputs: bool, return_dict: bool = False):
inputs = get_sample_jump_times_inputs(
self.config,
self.device,
batch_size=2,
sequence_length=8,
num_alignment_heads=6,
sot_sequence_length=3,
segment_length=1332,
use_fp16=use_fp16_inputs,
use_int32=use_int32_inputs,
)
if return_dict:
return inputs
return (
inputs["alignment_heads"],
inputs["sot_sequence_length"],
inputs["segment_length"],
inputs["QKs"],
)
def create_torch_ops(self):
"""
1) Create UnfoldTensor and DynamicTimeWarping as torch ops
3) Provide a symbolic mapping from torch ops to ORT contrib ops
See https://pytorch.org/tutorials/advanced/torch_script_custom_ops.html#building-with-jit-compilation
for more details on how this works.
"""
# Set torch extensions directory to cache directory
os.environ["TORCH_EXTENSIONS_DIR"] = self.cache_dir
# Try to import `ninja` pip package
try:
assert torch.utils.cpp_extension.verify_ninja_availability()
except Exception as e:
logger.error(f"An error occurred while verifying `ninja` is available: {e}", exc_info=True) # noqa: G201
install_cmd = "pip install ninja"
logger.warning(f"Could not import `ninja`. Attempting to install `ninja` via `{install_cmd}`.")
os.system(install_cmd)
# Create UnfoldTensor torch op
unfold_op_source = textwrap.dedent("""\
#include "torch/script.h"
torch::Tensor UnfoldTensor(torch::Tensor input, int64_t dim, int64_t size, int64_t step) {
return input.unfold(dim, size, step);
}
// namespace is onnxruntime
static auto registry = torch::RegisterOperators("onnxruntime::UnfoldTensor", &UnfoldTensor);
""")
torch.utils.cpp_extension.load_inline(
name="UnfoldTensor",
cpp_sources=unfold_op_source,
is_python_module=False,
verbose=True,
)
# Create DynamicTimeWarping torch op
dtw_op_source = textwrap.dedent("""\
#include "torch/script.h"
#include "torch/torch.h"
#include <stdexcept>
#include <tuple>
#include <vector>
torch::Tensor Backtrace(torch::Tensor trace) {
int64_t i = trace.size(0) - 1;
int64_t j = trace.size(1) - 1;
trace.index({0, torch::indexing::Slice()}) = 2;
trace.index({torch::indexing::Slice(), 0}) = 1;
std::vector<int32_t> result_vec;
while (i > 0 || j > 0) {
result_vec.push_back(static_cast<int32_t>(i - 1));
result_vec.push_back(static_cast<int32_t>(j - 1));
int value = trace[i][j].item<int>();
if (value == 0) {
i--;
j--;
} else if (value == 1) {
i--;
} else if (value == 2) {
j--;
} else {
throw std::runtime_error("Unexpected trace[i, j]");
}
}
// Compute result[::-1, :].T
torch::Tensor result = torch::from_blob(result_vec.data(), {static_cast<long int>(result_vec.size() / 2), 2}, torch::kInt32).clone();
torch::Tensor reversed = result.flip(0); // result[::-1, :]
torch::Tensor transposed = reversed.transpose(0, 1); // .T
return transposed;
}
torch::Tensor DynamicTimeWarping(torch::Tensor x) {
int64_t N = x.size(0);
int64_t M = x.size(1);
torch::Tensor cost = torch::full({N + 1, M + 1}, std::numeric_limits<float>::infinity(), torch::dtype(torch::kFloat32));
torch::Tensor trace = torch::full({N + 1, M + 1}, -1, torch::dtype(torch::kFloat32));
cost[0][0] = 0;
for (int j = 1; j < M + 1; j++) {
for (int i = 1; i < N + 1; i++) {
float c0 = cost[i - 1][j - 1].item<float>();
float c1 = cost[i - 1][j].item<float>();
float c2 = cost[i][j - 1].item<float>();
float c = 0;
float t = 0;
if (c0 < c1 && c0 < c2) {
c = c0;
t = 0;
} else if (c1 < c0 && c1 < c2) {
c = c1;
t = 1;
} else {
c = c2;
t = 2;
}
cost[i][j] = x[i - 1][j - 1].item<float>() + c;
trace[i][j] = t;
}
}
return Backtrace(trace);
}
// namespace is onnxruntime
static auto registry = torch::RegisterOperators("onnxruntime::DynamicTimeWarping", &DynamicTimeWarping);
""")
torch.utils.cpp_extension.load_inline(
name="DynamicTimeWarping",
cpp_sources=dtw_op_source,
is_python_module=False,
verbose=True,
)
# Create symbolic mapping from torch ops to ORT contrib ops
pytorch_export_contrib_ops.register()
def export_onnx(
self,
onnx_model_path: str,
provider: str,
verbose: bool = True,
use_external_data_format: bool = False,
use_fp16_inputs: bool = False,
use_int32_inputs: bool = True,
):
"""Export word-level timestamps to ONNX
Args:
onnx_model_path (str): path to save ONNX model
provider (str): provider to use for verifying parity on ONNX model
verbose (bool, optional): print verbose information. Defaults to True.
use_external_data_format (bool, optional): use external data format or not. Defaults to False.
use_fp16_inputs (bool, optional): use float16 inputs for the audio_features. Defaults to False.
use_int32_inputs (bool, optional): use int32 inputs for the decoder_input_ids. Defaults to True.
"""
# Shape of timestamps's tensors:
# Inputs:
# alignment_heads: (num_alignment_heads, 2)
# sot_sequence_length: (1)
# segment_length: (1)
# cross_qk_*: (batch_size, num_heads, sequence_length, num_frames // 2)
# Outputs:
# jump_times: (batch_size, max_length)
# Definitions:
# alignment_heads: the attention head indices where the Q*K values are highly correlated with word-level timestamps
# (i.e. the alignment between audio and text tokens)
# This is calculated as follows:
#
# ```
# import base64
# import gzip
# import numpy as np
# import torch
#
# # base85-encoded (n_layers, n_heads) boolean arrays indicating the cross-attention heads that are
# # highly correlated to the word-level timing, i.e. the alignment between audio and text tokens.
# _ALIGNMENT_HEADS = {
# "tiny.en": b"ABzY8J1N>@0{>%R00Bk>$p{7v037`oCl~+#00",
# "tiny": b"ABzY8bu8Lr0{>%RKn9Fp%m@SkK7Kt=7ytkO",
# "base.en": b"ABzY8;40c<0{>%RzzG;p*o+Vo09|#PsxSZm00",
# "base": b"ABzY8KQ!870{>%RzyTQH3`Q^yNP!>##QT-<FaQ7m",
# "small.en": b"ABzY8>?_)10{>%RpeA61k&I|OI3I$65C{;;pbCHh0B{qLQ;+}v00",
# "small": b"ABzY8DmU6=0{>%Rpa?J`kvJ6qF(V^F86#Xh7JUGMK}P<N0000",
# "medium.en": b"ABzY8usPae0{>%R7<zz_OvQ{)4kMa0BMw6u5rT}kRKX;$NfYBv00*Hl@qhsU00",
# "medium": b"ABzY8B0Jh+0{>%R7}kK1fFL7w6%<-Pf*t^=N)Qr&0RR9",
# "large-v1": b"ABzY8r9j$a0{>%R7#4sLmoOs{s)o3~84-RPdcFk!JR<kSfC2yj",
# "large-v2": b"ABzY8zd+h!0{>%R7=D0pU<_bnWW*tkYAhobTNnu$jnkEkXqp)j;w1Tzk)UH3X%SZd&fFZ2fC2yj",
# "large-v3": b"ABzY8gWO1E0{>%R7(9S+Kn!D~%ngiGaR?*L!iJG9p-nab0JQ=-{D1-g00",
# "large": b"ABzY8gWO1E0{>%R7(9S+Kn!D~%ngiGaR?*L!iJG9p-nab0JQ=-{D1-g00",
# "large-v3-turbo": b"ABzY8j^C+e0{>%RARaKHP%t(lGR*)0g!tONPyhe`",
# "turbo": b"ABzY8j^C+e0{>%RARaKHP%t(lGR*)0g!tONPyhe`",
# }
#
# model_name = "large-v3-turbo"
# array = np.frombuffer(
# gzip.decompress(base64.b85decode(_ALIGNMENT_HEADS[model_name])), dtype=bool
# ).copy()
# mask = torch.from_numpy(array).reshape(
# self.dims.n_text_layer, self.dims.n_text_head
# )
# self.alignment_heads = mask.to_sparse().indices().T
# ```
#
# sot_sequence_length: the length of the start-of-transcription sequence before the first token is generated
# Typically the start-of-transcription sequence is [<|startoftranscription|>, <|language_token|>, <|task_token|>]
# so its length is 3.
#
# segment_length: the length (in frames) of the audio segment that is being transcribed
#
# cross_qk_*: the Q*K values for the cross-attention blocks in the decoder
# Every decoder layer has a self-attention block and a cross-attention block so there are `n` cross-attention blocks
# where `n` is the number of decoder layers.
#
# jump_times: the timings where jumps occur in speech
# This allows us to detect when a word began to be spoken by the speaker (start_times) and when a word was finished
# being spoken by the speaker (end_times).
inputs = self.inputs(use_fp16_inputs=use_fp16_inputs, use_int32_inputs=use_int32_inputs)
input_names = self.input_names()
output_names = self.output_names()
dynamic_axes = get_model_dynamic_axes(self.config, input_names, output_names)
Path(onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp_dir_name:
temp_onnx_model_path = os.path.join(tmp_dir_name, "encoder.onnx")
Path(temp_onnx_model_path).parent.mkdir(parents=True, exist_ok=True)
out_path = temp_onnx_model_path if use_external_data_format else onnx_model_path
# Create torch ops and map them to ORT contrib ops before export
self.create_torch_ops()
torch.onnx.export(
self,
args=inputs,
f=out_path,
export_params=True,
input_names=input_names,
output_names=output_names,
dynamic_axes=dynamic_axes,
opset_version=17,
do_constant_folding=True,
verbose=verbose,
custom_opsets={"com.microsoft": 1},
)
if use_external_data_format:
model = onnx.load_model(out_path, load_external_data=use_external_data_format)
OnnxModel.save(
model,
onnx_model_path,
save_as_external_data=True,
all_tensors_to_one_file=True,
)
self.verify_onnx(onnx_model_path, provider, use_fp16_inputs, use_int32_inputs)
def verify_onnx(
self,
onnx_model_path: str,
provider: str,
use_fp16_inputs: bool,
use_int32_inputs: bool,
):
"""Verify ONNX model outputs and PyTorch model outputs match
Args:
onnx_model_path (str): path to save ONNX model
provider (str): execution provider for ONNX model
use_fp16_inputs (bool, optional): use float16 inputs for the cross_qk_{i}
use_int32_inputs (bool, optional): use int32 inputs for the alignment_heads and sot_sequence_length
"""
# Shape of jump times's tensors:
# Inputs:
# alignment_heads: (num_alignment_heads, 2)
# sot_sequence_length: (1)
# segment_length: (1)
# cross_qk_*: (batch_size, num_heads, sequence_length, num_frames // 2)
# Outputs:
# jump_times: (batch_size, max_length)
inputs = self.inputs(use_fp16_inputs=use_fp16_inputs, use_int32_inputs=use_int32_inputs, return_dict=True)
# Run PyTorch model
pt_outputs = (
self.forward(
inputs["alignment_heads"], inputs["sot_sequence_length"], inputs["segment_length"], inputs["QKs"]
)
.detach()
.cpu()
.numpy()
)
# Run ONNX model
sess = InferenceSession(onnx_model_path, providers=[provider])
ort_outputs = sess.run(None, convert_inputs_for_ort(inputs, sess))
# Calculate output difference
diff = np.abs(pt_outputs - ort_outputs)
print("Comparing batched jump_times...", flush=True)
print(f"Max diff: {np.max(diff)}", flush=True)