Files
Liger-Kernel/benchmark/scripts/utils.py
T
Fuxiang DuandArup De 9384e4096f bench(flce): add configurable chunk-memory sweep (#1479)
## Summary

- Add an opt-in benchmark sweep for FLCE `chunk_mem_const` values.
- Encode each chunk budget in the provider name so CSV speed and memory
rows remain distinguishable.
- Allow benchmark callers to configure Triton `do_bench` warmup and
repetition windows.
- Use longer measurement windows only when the chunk sweep is enabled.
- Reject combining the Triton chunk sweep with
`LIGER_KERNEL_IMPL=cutile`.
- Preserve existing benchmark providers, timings, and production FLCE
defaults when the sweep is disabled.

## Details

The shared benchmark repetition window can be shorter than a single FLCE
iteration for large-vocabulary shapes. In that case,
`triton.testing.do_bench` can effectively collect one sample and report
identical p20, p50, and p80 values.

Enable the sweep with:

```bash
LIGER_FLCE_BENCH_CHUNK_MEM_CONSTS="1,2,4,8,16" \
LIGER_FLCE_BENCH_REP_MS="5000" \
LIGER_BENCH_TARGET="flce_chunk_sweep" \
python benchmark/scripts/benchmark_fused_linear_cross_entropy.py \
  --model llama_3_8b \
  --overwrite
```

The resulting providers are recorded as `liger-chunk-c1`,
`liger-chunk-c2`, and so on.

Without `LIGER_FLCE_BENCH_CHUNK_MEM_CONSTS`, the script retains the
existing provider set and the shared 25 ms warmup / 10 ms repetition
defaults.

This is a benchmark-only change. It does not modify the production chunk
policy or recommend a hardware-wide default. Local SM89 measurements
showed that the speed/memory trade-off varies with token count and
vocabulary size.

This complements #1439: that PR exposes runtime chunk-size control,
while this PR provides reproducible tooling for evaluating chunk-memory
policies.

## Testing Done

- Hardware Type: NVIDIA GeForce RTX 4070 Laptop GPU (SM89), BF16, WSL2
- [x] run `make checkstyle` to ensure code style
- [ ] run `make test` to ensure correctness
- The single-process suite was terminated by the local WSL2 host at 91%
and 93% on two attempts, in different transformer test files, consistent
with cumulative host-memory pressure.
  - `pytest -q test/ops/test_fused_linear_cross_entropy.py`: 144 passed.
- `pytest -q test/transformers/test_rms_norm.py`: 64 passed, 8 expected
XFAIL.
- [ ] run `make test-convergence` to ensure convergence
- Not completed locally. This PR changes benchmark tooling only and does
not modify production kernel behavior.

Representative benchmark validation:

- comma-separated chunk values are preserved in provider order;
- duplicate values are removed;
- invalid and non-positive values are rejected;
- a configurable repetition window produces non-degenerate p20/p50/p80
values for long-running C=1 paths;
- ascending and descending large-vocabulary sweeps showed less than 1.1%
maximum median order difference;
- default execution remains unchanged when the sweep environment
variables are absent.

Co-authored-by: Arup De <[email protected]>
2026-10-06 03:39:48 +00:00

529 lines
18 KiB
Python

import argparse
import csv
import json
import os
import time
from collections import OrderedDict
from dataclasses import asdict
from dataclasses import dataclass
from importlib.metadata import version
from itertools import zip_longest
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Union
import torch
from liger_kernel.utils import infer_device
device = infer_device()
LIGER_KERNEL_VERSION = version("liger-kernel")
QUANTILES = [0.5, 0.2, 0.8]
@dataclass
class SingleBenchmarkRunInput:
x: Union[int, float, str]
kernel_provider: str
kernel_operation_mode: Optional[str] = ""
extra_benchmark_config: Optional[Dict[str, Any]] = None
@dataclass
class SingleBenchmarkRunOutput:
# 20th percentile
y_20: float
# 50th percentile (median)
y_50: float
# 80th percentile
y_80: float
@dataclass
class BenchmarkData:
"""
BenchmarkData is a dataclass to store the benchmark data for a a completed benchmark
run on all x-values for a given kernel/kernel operation mode/metric/extra_benchmark_config
"""
kernel_name: str
kernel_provider: str
metric_name: str
metric_unit: str
gpu_name: str
x_name: str
x_label: str
x_values: List[Union[float, str]]
y_values_50: List[float]
y_values_20: List[float]
y_values_80: List[float]
timestamp: str
kernel_operation_mode: Optional[str] = None
extra_benchmark_config_str: Optional[str] = None
liger_version: str = LIGER_KERNEL_VERSION
@dataclass
class BenchmarkDataCSVRow:
# The ordering of field names here will be the order of columns in the CSV
kernel_name: str
kernel_provider: str
kernel_operation_mode: Union[str, None]
metric_name: str
metric_unit: str
x_name: str
x_label: str
x_value: Union[float, str]
y_value_50: float
y_value_20: float
y_value_80: float
extra_benchmark_config_str: Union[str, None]
gpu_name: str
timestamp: str
liger_version: str
def _test_memory(
func: Callable,
_iter: int = 10,
quantiles: Optional[List[float]] = None,
return_mode="mean",
) -> float:
assert return_mode in ["min", "max", "mean", "median"]
total_mem = []
for _ in range(_iter):
getattr(torch, device).memory.reset_peak_memory_stats()
func()
# Convert to MB
mem = getattr(torch, device).max_memory_allocated() / 2**20
total_mem.append(mem)
total_mem = torch.tensor(total_mem, dtype=torch.float)
if quantiles is not None:
quantiles_data = torch.quantile(total_mem, torch.tensor(quantiles, dtype=torch.float)).tolist()
if len(quantiles_data) == 1:
quantiles_data = quantiles_data[0]
return quantiles_data
return getattr(torch, return_mode)(total_mem).item()
def run_speed_benchmark(
fwd_fn: Callable,
mode: str,
input_tensors: List[torch.Tensor],
rep: int = 10,
warmup: int = 25,
) -> "SingleBenchmarkRunOutput":
"""Measure execution speed for forward, backward, or full (fwd+bwd).
Covers the common case where the forward function returns a single tensor
and backward uses a random gradient of the same shape. For kernels with
scalar output (losses) or multiple outputs (e.g. RoPE), write custom
measurement logic instead.
"""
import triton
if mode == "forward":
ms_50, ms_20, ms_80 = triton.testing.do_bench(
fwd_fn,
grad_to_none=input_tensors,
warmup=warmup,
rep=rep,
quantiles=QUANTILES,
)
elif mode == "backward":
y = fwd_fn()
do = torch.randn_like(y)
ms_50, ms_20, ms_80 = triton.testing.do_bench(
lambda: y.backward(do, retain_graph=True),
grad_to_none=input_tensors,
warmup=warmup,
rep=rep,
quantiles=QUANTILES,
)
elif mode == "full":
def full():
y = fwd_fn()
y.backward(torch.randn_like(y), retain_graph=True)
ms_50, ms_20, ms_80 = triton.testing.do_bench(
full,
grad_to_none=input_tensors,
warmup=warmup,
rep=rep,
quantiles=QUANTILES,
)
elif mode == "no-grad-forward":
def no_grad_forward():
with torch.no_grad():
fwd_fn()
ms_50, ms_20, ms_80 = triton.testing.do_bench(
no_grad_forward,
warmup=warmup,
rep=rep,
quantiles=QUANTILES,
)
else:
raise ValueError(f"Unsupported mode: {mode}. Use 'forward', 'backward', or 'full'.")
return SingleBenchmarkRunOutput(y_20=ms_20, y_50=ms_50, y_80=ms_80)
def run_memory_benchmark(
fwd_fn: Callable,
mode: str,
) -> "SingleBenchmarkRunOutput":
"""Measure peak memory for forward, backward, or full (fwd+bwd).
Same caveats as :func:`run_speed_benchmark` regarding output shape.
"""
if mode == "forward":
mem_50, mem_20, mem_80 = _test_memory(fwd_fn, quantiles=QUANTILES)
elif mode == "backward":
y = fwd_fn()
do = torch.randn_like(y)
mem_50, mem_20, mem_80 = _test_memory(
lambda: y.backward(do, retain_graph=True),
quantiles=QUANTILES,
)
elif mode == "full":
def full():
y = fwd_fn()
y.backward(torch.randn_like(y), retain_graph=True)
mem_50, mem_20, mem_80 = _test_memory(full, quantiles=QUANTILES)
else:
raise ValueError(f"Unsupported mode: {mode}. Use 'forward', 'backward', or 'full'.")
return SingleBenchmarkRunOutput(y_20=mem_20, y_50=mem_50, y_80=mem_80)
def default_forward_fn(*setup_out):
x, layer = setup_out[0], setup_out[1]
return layer(x)
def build_speed_bench_fn(
setup_fn: Callable[["SingleBenchmarkRunInput"], Any],
forward_fn: Callable[..., torch.Tensor] = default_forward_fn,
warmup: int = 25,
rep: int = 10,
) -> Callable:
def bench_speed(input: "SingleBenchmarkRunInput") -> SingleBenchmarkRunOutput:
setup_out = setup_fn(input)
return run_speed_benchmark(
lambda: forward_fn(*setup_out),
input.kernel_operation_mode,
[setup_out[0]],
warmup=warmup,
rep=rep,
)
return bench_speed
def build_memory_bench_fn(
setup_fn: Callable[["SingleBenchmarkRunInput"], Any],
forward_fn: Callable[..., torch.Tensor] = default_forward_fn,
) -> Callable:
def bench_memory(input: "SingleBenchmarkRunInput") -> SingleBenchmarkRunOutput:
setup_out = setup_fn(input)
return run_memory_benchmark(lambda: forward_fn(*setup_out), input.kernel_operation_mode)
return bench_memory
def get_current_file_directory() -> str:
"""
Returns the directory path of the current Python file.
"""
# Get the absolute path of the current file
current_file_path = os.path.abspath(__file__)
# Get the directory path of the current file
return os.path.dirname(current_file_path)
def sleep(seconds):
def decorator(function):
def wrapper(*args, **kwargs):
time.sleep(seconds)
return function(*args, **kwargs)
return wrapper
return decorator
def _print_benchmarking_banner(metric_name: str, kernel_name: str):
print("**************************************")
print(f" BENCHMARKING {metric_name.upper()} for {kernel_name.upper()}")
print("**************************************")
def get_formatted_time():
return time.strftime("%Y-%m-%d %H:%M:%S")
def get_gpu_name():
"""
Returns the current GPU name, formatted to serve as a directory name
"""
torch_device = getattr(torch, device)
if torch_device.is_available():
gpu_name = torch_device.get_device_name(torch_device.current_device())
return gpu_name
else:
raise Exception("Benchmarks can only be run on GPU.")
def update_benchmark_data_csv(
benchmark_data_list: List[BenchmarkData],
filename: str = "all_benchmark_data.csv",
overwrite: bool = True,
):
"""
Update the CSV file with the new benchmark data. If the file does not exist, create it.
If an entry already exists for the benchmark, then overwrite it if `overwrite` is True.
"""
def create_unique_key(row):
# This unique key is used to determine if a benchmark run already exists in the CSV
# If the key is the same, then the benchmark run already exists and will optionally
# be overwritten. Otherwise, it is considered a new benchmark run and appended.
return (
row["kernel_name"],
row["kernel_provider"],
row["kernel_operation_mode"] if row["kernel_operation_mode"] else "",
row["metric_name"],
row["x_name"],
str(row["x_value"]),
(row["extra_benchmark_config_str"] if row["extra_benchmark_config_str"] else ""),
row["gpu_name"],
)
fieldnames = BenchmarkDataCSVRow.__annotations__.keys()
# Make filename path relative to current file
filename_abs_path = os.path.join(get_current_file_directory(), "../data", filename)
file_exists = os.path.isfile(filename_abs_path)
# Read existing data into a list of dicts
existing_data = []
if file_exists:
with open(filename_abs_path, mode="r") as file:
reader = csv.DictReader(file)
for row in reader:
existing_data.append(row)
existing_data_dict = OrderedDict((create_unique_key(row), row) for row in existing_data)
for benchmark_data in benchmark_data_list:
benchmark_data_dict = asdict(benchmark_data)
x_values = benchmark_data_dict.pop("x_values")
y_values_50 = benchmark_data_dict.pop("y_values_50")
y_values_20 = benchmark_data_dict.pop("y_values_20")
y_values_80 = benchmark_data_dict.pop("y_values_80")
# Need to convert benchmark_data into multiple rows based on x_values and y_values
for x_value, y_value_50, y_value_20, y_value_80 in zip_longest(x_values, y_values_50, y_values_20, y_values_80):
if y_value_50 is None:
y_value_50 = float("nan")
if y_value_20 is None:
y_value_20 = float("nan")
if y_value_80 is None:
y_value_80 = float("nan")
row = BenchmarkDataCSVRow(
x_value=x_value,
y_value_50=y_value_50,
y_value_20=y_value_20,
y_value_80=y_value_80,
**benchmark_data_dict,
)
row_dict = asdict(row)
row_key = create_unique_key(row_dict)
if row_key in existing_data_dict:
if overwrite:
# If overwriting, update the row
existing_data_dict[row_key] = row_dict
else:
# If not overwriting, skip this row
pass
else:
existing_data_dict[row_key] = row_dict
os.makedirs(os.path.dirname(filename_abs_path), exist_ok=True)
with open(filename_abs_path, mode="w", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for row in existing_data_dict.values():
writer.writerow(row)
class CustomEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, torch.dtype):
return str(obj)
return super().default(self, obj)
def print_benchmark_data(benchmark_data_list: List[BenchmarkData]) -> str:
print("********** Benchmark Data **********")
formatted_list = [obj.__dict__ for obj in benchmark_data_list]
print(json.dumps(formatted_list, indent=2))
def run_benchmarks(
bench_test_fn: Callable,
kernel_name: str,
metric_name: str,
metric_unit: str,
x_name: str,
x_label: str,
x_values: List[Union[float, int, str]],
kernel_providers: List[str],
kernel_operation_modes: Optional[List[str]] = [None],
extra_benchmark_configs: Optional[List[Dict[str, Any]]] = None,
overwrite: bool = False,
):
"""
Run benchmarks given a bench_test_fn that takes in a SingleBenchmarkRunInput as input and
saves data to the CSV file.
Args:
- bench_test_fn: The benchmark test function to run. This function should take in a
SingleBenchmarkRunInput as input and return a SingleBenchmarkRunOutput.
- kernel_name: The name of the kernel being benchmarked (e.g. "swiglu")
- metric_name: The name of the metric being benchmarked (e.g. "speed" or "memory")
- metric_unit: The unit of the metric being benchmarked (e.g. "ms" or "MB")
- x_name: The name of the x-axis (e.g. "T" for sequence length)
- x_label: The label of the x-axis (e.g. "sequence length")
- x_values: The list of x-values to run the benchmark on (e.g. [2**i for i in range(10, 14)])
- kernel_providers: The list of kernel providers to run the benchmark on (e.g. ["liger", "huggingface"])
- kernel_operation_modes: The list of kernel operation modes to run the benchmark on (e.g. ["full", "backward"])
- extra_benchmark_configs: The list of extra benchmark configurations to run the benchmark on.
- overwrite: Whether to overwrite the existing benchmark data entry if it already exists.
"""
assert len(kernel_operation_modes) >= 1
assert len(kernel_providers) >= 1
_print_benchmarking_banner(metric_name=metric_name, kernel_name=kernel_name)
gpu_name = get_gpu_name()
benchmark_data_list = []
for extra_benchmark_config in extra_benchmark_configs:
for kernel_operation_mode in kernel_operation_modes:
for kernel_provider in kernel_providers:
y_values_50 = []
y_values_20 = []
y_values_80 = []
for x in x_values:
single_benchmark_run_input = SingleBenchmarkRunInput(
x=x,
kernel_provider=kernel_provider,
kernel_operation_mode=kernel_operation_mode,
extra_benchmark_config=extra_benchmark_config,
)
benchmark_result: SingleBenchmarkRunOutput = bench_test_fn(single_benchmark_run_input)
y_values_50.append(benchmark_result.y_50)
y_values_20.append(benchmark_result.y_20)
y_values_80.append(benchmark_result.y_80)
benchmark_run_data = BenchmarkData(
kernel_name=kernel_name,
kernel_operation_mode=kernel_operation_mode,
kernel_provider=kernel_provider,
metric_name=metric_name,
metric_unit=metric_unit,
gpu_name=gpu_name,
x_name=x_name,
x_label=x_label,
x_values=x_values,
y_values_50=y_values_50,
y_values_20=y_values_20,
y_values_80=y_values_80,
extra_benchmark_config_str=json.dumps(extra_benchmark_config, cls=CustomEncoder),
timestamp=get_formatted_time(),
liger_version=LIGER_KERNEL_VERSION,
)
benchmark_data_list.append(benchmark_run_data)
# Output routing for comparison runs.
# LIGER_BENCH_TARGET selects the destination CSV (e.g. "cutile" -> all_benchmark_data_cutile.csv).
# LIGER_BENCH_PROVIDER_TAG renames Liger providers in this run (e.g. "liger_cutile") so
# multiple implementations can coexist in one CSV without colliding on the dedup key.
# The tag replaces the leading "liger" of each provider, preserving any suffix (hyphen or
# underscore) so benchmarks with several Liger providers stay distinct:
# "liger" -> "liger_cutile"
# "liger-fp32-accum" -> "liger_cutile-fp32-accum"
# "liger_fused_add_rms_norm" -> "liger_cutile_fused_add_rms_norm"
target = os.environ.get("LIGER_BENCH_TARGET", "").strip().lower()
provider_tag = os.environ.get("LIGER_BENCH_PROVIDER_TAG", "").strip().lower()
if provider_tag:
for bd in benchmark_data_list:
if bd.kernel_provider.startswith("liger"):
bd.kernel_provider = provider_tag + bd.kernel_provider[len("liger") :]
print_benchmark_data(benchmark_data_list)
file_name = f"all_benchmark_data_{target}.csv" if target else "all_benchmark_data.csv"
update_benchmark_data_csv(benchmark_data_list=benchmark_data_list, filename=file_name, overwrite=overwrite)
def parse_benchmark_script_args():
parser = argparse.ArgumentParser(description="Benchmarking script for Liger-Kernel")
parser.add_argument(
"--overwrite",
action="store_true",
help="Flag to overwrite existing benchmark data with current run.",
)
parser.add_argument(
"--sweep-mode",
type=str,
default="token_length",
choices=["token_length", "model_config"],
help=(
"Benchmark sweep dimension. "
"'token_length': sweep sequence length with fixed model. "
"'model_config': sweep model configs with fixed token length."
),
)
parser.add_argument(
"--model",
type=str,
default=None,
help=(
"Model config name from MODEL_REGISTRY "
"(e.g. llama_2_7b, llama_3_8b). "
"Defaults to llama_3_8b when not specified."
),
)
parser.add_argument(
"--bt",
type=int,
default=2048,
help=(
"Target total tokens (batch_size * seq_len) for model-config "
"sweep. Only used when --sweep-mode=model_config."
),
)
args = parser.parse_args()
return args