pinpinpoo opened a new issue, #20342:
URL: https://github.com/apache/tvm/issues/20342

   Enabling `FuseOps`/`FuseTIR` causes a performance regression for an FP16 Exp 
chain followed by a broadcast Add on an RTX A6000. VM latency increases by 30% 
with 8 Exp layers and 60% with 16 layers compared with disabling these passes. 
Fusing the Exp chain while keeping the broadcast Add separate is faster than 
either configuration.
   
   ### Workload
   
   Inputs are `x: [4096, 1]` and `b: [4096, 4096]`, both FP16:
   
   ```python
   z = x
   for _ in range(depth):
       z = exp(x - float16(0.5) * z)
   y = b + z
   ```
   
   Inputs are sampled from `[-0.25, 0.25]` with fixed seeds. This recurrence 
keeps the Exp chain finite. The reference rounds to FP16 after each operator.
   
   ### Results
   
   The three configurations have the same inputs and a single `[4096, 4096]` 
output:
   
   - **OFF:** skip `FuseOps` and `FuseTIR` in the CUDA pipeline.
   - **PARTIAL:** place the Exp producer and broadcast Add in separate dataflow 
blocks, then run the full pipeline.
   - **ON:** run the full pipeline on a single dataflow block.
   
   | Exp layers | ON/OFF | PARTIAL/OFF | ON/PARTIAL | Trials passing the 
stability and 10% slowdown thresholds |
   |---|---:|---:|---:|---:|
   | 8 | 1.301768 | 0.793551 | 1.641148 | 5/5 |
   | 16 | 1.595166 | 0.646946 | 2.444368 | 4/5 |
   
   Ratios are latency ratios, summarized as the median of five per-trial 
ratios. For 16 layers, ON/OFF was `1.584963, 1.595166, 1.602671, 1.615977, 
1.569858`. One trial exceeded the 5% CV threshold; it is included in the table. 
GPU clocks were not locked.
   
   Each trial runs in a separate process. Timing uses 
`vm.time_evaluator("invoke_stateful", ...)` with GPU-resident inputs: 
`number=10`, `repeat=5`, `min_repeat_ms=50`, and 40 measurement blocks, giving 
200 samples per configuration. Warmup is 75 calls followed by 2 seconds of 
timed batches. The six configuration orders are cycled across blocks and offset 
across trials. The measured time includes VM allocations; compilation, 
transfers and correctness checks are outside the timer.
   
   All three configurations passed reference and pairwise checks on three input 
draws per trial, with zero maximum absolute error. The saved IR confirms that 
ON fuses the Exp-to-Add edge and PARTIAL retains a fused producer followed by a 
separate Add. Cubin hashes match across the five trials for each configuration.
   
   ### Environment
   
   - TVM: `0.26.dev0+source.8f328e8`
   - Source commit recorded in the build manifest: 
`8f328e802cfe5e41fcc8f5c17e7582b1c28bfce4`
   - GPU: NVIDIA RTX A6000, `sm_86`; driver 550.120
   - CUDA toolkit: 12.4.131; cuda-bindings: 12.9.7
   - LLVM: 15.0.7; host target: `llvm -mcpu=generic`
   - Linux x86_64, Python 3.11.16, NumPy 2.4.6
   
   ### Reproduction
   
   Save the code below as `repro_broadcast_regression.py` and run in a 
CUDA-enabled TVM environment with NumPy installed:
   
   ```bash
   python repro_broadcast_regression.py --depth 8 --device 0 --out repro_depth8
   python repro_broadcast_regression.py --depth 16 --device 0 --out 
repro_depth16
   ```
   
   Use an idle GPU and a new output directory for each command. Each command 
runs five trials and saves per-pass IR, correctness results, timing samples and 
ratios. `summary.json` contains the median ratios and the number of stable 
trials with at least a 10% slowdown.
   
   The script checks for `3 * depth + 1`, `2`, and `1` calls to 
`relax.call_tir` before VM lowering in OFF, PARTIAL, and ON. A failure here 
means the generated graph needs to be inspected before comparing timings.
   
   I extracted this script from the benchmark used for the measurements above; 
I haven't rerun the standalone version on CUDA yet.
   
   ```python
   """Standalone extraction for TVM 0.26.dev0+source.8f328e8 / CUDA.
   
   Run twice: --depth 8 --out repro_d8, then --depth 16 --out repro_d16.
   Each command runs five separate processes. No benchmark-package imports.
   """
   import argparse
   import itertools
   import json
   import math
   from pathlib import Path
   import platform
   import statistics
   import subprocess
   import sys
   import time
   
   VARIANTS = ("off", "partial", "on")
   ORDERS = list(itertools.permutations(VARIANTS))
   
   
   def reference(x, b, depth):
       import numpy as np
       z = x
       for _ in range(depth):
           z = (x - (z * np.float16(0.5)).astype("float16")).astype("float16")
           z = np.exp(z.astype("float64")).astype("float16")
       return (z + b).astype("float16")
   
   
   def graph(depth, partial):
       from tvm import relax as R
       from tvm.relax import op
       x = R.Var("x", R.TensorType([4096, 1], "float16"))
       b = R.Var("b", R.TensorType([4096, 4096], "float16"))
       bb = R.BlockBuilder()
   
       def producer():
           z = x
           for _ in range(depth):
               z = bb.emit(op.multiply(z, R.const(0.5, "float16")))
               z = bb.emit(op.subtract(x, z))
               z = bb.emit(op.exp(z))
           return z
   
       with bb.function("main", [x, b]):
           if partial:
               with bb.dataflow():
                   z = bb.emit_output(producer())
               with bb.dataflow():
                   y = bb.emit_output(bb.emit(op.add(z, b)))
           else:
               with bb.dataflow():
                   y = bb.emit_output(bb.emit(op.add(producer(), b)))
           bb.emit_func_output(y)
       return bb.get()
   
   
   def compile_vm(depth, mode, target, dev, out):
       import tvm
       from tvm import relax as R
       from tvm.relax.backend.cuda import pipeline
       out.mkdir()
       mod = graph(depth, mode == "partial")
       (out / "input.py").write_text(mod.script(), encoding="utf-8")
   
       @tvm.instrument.pass_instrument
       class Audit:
           def run_before_pass(self, mod, info):
               if mode == "off" and str(info.name).split(".")[-1] in 
("FuseOps", "FuseTIR"):
                   raise RuntimeError("Unexpected fusion pass in OFF")
   
       with target, tvm.transform.PassContext(opt_level=3, 
instruments=[Audit()]):
           steps = pipeline.library_dispatch_passes(target) + 
pipeline.legalize_passes(target)
           for i, p in enumerate(steps):
               name = str(p.info.name).split(".")[-1]
               if mode == "off" and name in ("FuseOps", "FuseTIR"):
                   continue
               mod = p(mod)
               (out / f"{i:02d}_{name}.py").write_text(mod.script(), 
encoding="utf-8")
           calls = []
   
           def visit(expr):
               if isinstance(expr, R.Call) and isinstance(expr.op, tvm.ir.Op):
                   if expr.op.name == "relax.call_tir":
                       calls.append(expr.args[0].name_hint)
   
           R.analysis.post_order_visit(mod["main"].body, visit)
           expected = {"off": 3 * depth + 1, "partial": 2, "on": 1}[mode]
           if len(calls) != expected:
               raise RuntimeError(f"{mode}: expected {expected} call_tir calls, 
got {calls}; inspect IR")
           for p in pipeline.dataflow_lower_passes(target) + 
pipeline.finalize_passes(target):
               mod = p(mod)
           ex = R.build(mod, target=target, relax_pipeline=None, 
tir_pipeline="default")
       return R.VirtualMachine(ex, dev)
   
   
   def stats(samples):
       if len(samples) != 200 or any(not math.isfinite(x) or x <= 0 for x in 
samples):
           raise ValueError("Expected 200 finite positive samples")
       return dict(median_s=statistics.median(samples),
                   cv=statistics.stdev(samples) / statistics.mean(samples), 
samples_s=samples)
   
   
   def trial(args):
       import numpy as np
       import tvm
       dev = tvm.cuda(args.device)
       if not dev.exist:
           raise RuntimeError("CUDA device unavailable")
       target = tvm.target.Target(
           {"kind": "cuda", "arch": "sm_" + 
str(dev.compute_version).replace(".", "")},
           host={"kind": "llvm", "mcpu": "generic"})
       order = ORDERS[args.trial % 6]
       vms = {k: compile_vm(args.depth, k, target, dev, args.out / k) for k in 
order}
       errors = []
       for seed in range(3):
           rng = np.random.default_rng(seed)
           x = rng.uniform(-.25, .25, (4096, 1)).astype("float16")
           b = rng.uniform(-.25, .25, (4096, 4096)).astype("float16")
           arrays = [tvm.runtime.tensor(a, device=dev) for a in (x, b)]
           if seed == 0:
               timed_inputs = arrays
           expected, outputs = reference(x, b, args.depth), {}
           for k, vm in vms.items():
               vm.set_input("main", *arrays)
               vm.invoke_stateful("main")
               dev.sync()
               value = vm.get_outputs("main")
               while not hasattr(value, "numpy"):
                   if len(value) != 1:
                       raise AssertionError("Expected exactly one output")
                   value = value[0]
               outputs[k] = value.numpy()
           pairs = [(k, outputs[k], expected) for k in VARIANTS]
           pairs += [(f"{a}/{b}", outputs[a], outputs[b])
                     for a, b in itertools.combinations(VARIANTS, 2)]
           for label, actual, wanted in pairs:
               if actual.shape != wanted.shape or actual.dtype != wanted.dtype:
                   raise AssertionError(f"{label}: output shape/dtype mismatch")
               if not np.isfinite(actual).all() or not 
np.isfinite(wanted).all():
                   raise AssertionError(f"{label}: non-finite output")
               np.testing.assert_allclose(actual, wanted, rtol=.02, atol=.002)
               errors.append(dict(seed=seed, comparison=label,
                                  max_abs_error=float(np.max(np.abs(
                                      actual.astype("float32") - 
wanted.astype("float32"))))))
       timers, samples = {}, {k: [] for k in VARIANTS}
       for k in order:
           vm = vms[k]
           vm.set_input("main", *timed_inputs)
           timers[k] = vm.time_evaluator("invoke_stateful", dev, number=10,
                                        repeat=5, min_repeat_ms=50)
           for _ in range(75):
               vm.invoke_stateful("main")
           dev.sync()
           deadline = time.monotonic() + 2.0
           while time.monotonic() < deadline:
               dev.sync()
               timers[k]("main")
       for block in range(40):
           for k in ORDERS[(args.trial + block) % 6]:
               dev.sync()
               samples[k].extend(float(v) for v in timers[k]("main").results)
       measured = {k: stats(v) for k, v in samples.items()}
       ratios = {f"{a}/{b}": measured[a]["median_s"] / measured[b]["median_s"]
                 for a, b in (("on", "off"), ("partial", "off"), ("on", 
"partial"))}
       result = dict(depth=args.depth, trial=args.trial, tvm=tvm.__version__,
                     python=sys.version, platform=platform.platform(), 
target=str(target),
                     gpu=str(dev.device_name), correctness=errors, 
measured=measured, ratios=ratios,
                     on_off_stable=max(measured[k]["cv"] for k in ("on", 
"off")) <= .05)
       (args.out / "result.json").write_text(json.dumps(result, indent=2), 
encoding="utf-8")
       print(json.dumps({"trial": args.trial, "ratios": ratios,
                         "CV": {k: v["cv"] for k, v in measured.items()}}), 
flush=True)
   
   
   def main():
       parser = argparse.ArgumentParser(description=__doc__)
       parser.add_argument("--depth", type=int, choices=(8, 16), required=True)
       parser.add_argument("--device", type=int, default=0)
       parser.add_argument("--out", type=Path, required=True)
       parser.add_argument("--trial", type=int, help=argparse.SUPPRESS)
       args = parser.parse_args()
       if args.out.exists():
           parser.error("--out must be a new directory")
       args.out.mkdir(parents=True)
       if args.trial is not None:
           trial(args)
           return
       results = []
       for i in range(5):
           dest = args.out / f"trial_{i}"
           print(f"Depth {args.depth}, trial {i + 1}/5", flush=True)
           subprocess.run([sys.executable, str(Path(__file__).resolve()), 
"--depth", str(args.depth),
                           "--device", str(args.device), "--out", str(dest), 
"--trial", str(i)], check=True)
           results.append(json.loads((dest / 
"result.json").read_text(encoding="utf-8")))
       summary = dict(median_ratios={k: statistics.median(r["ratios"][k] for r 
in results)
                                    for k in results[0]["ratios"]},
                      stable_on_off_slow_trials=sum(r["on_off_stable"] and 
r["ratios"]["on/off"] >= 1.10
                                                   for r in results), 
total_trials=5)
       (args.out / "summary.json").write_text(json.dumps(summary, indent=2), 
encoding="utf-8")
       print(json.dumps(summary, indent=2))
   
   
   if __name__ == "__main__":
       main()
   ```
   
   ### Where the extra computation appears
   
   After `FuseTIR`, the fused PrimFunc still has `[4096, 1]` intermediate 
buffers and small producer loops, followed by the broadcast Add. After 
`ApplyDefaultSchedule`, the Exp chain is inlined into the `[4096, 4096]` output 
expression. The scheduling trace selects DLight `Fallback`.
   
   For 16 layers:
   
   - PARTIAL schedules the producer with 4 blocks of 1024 threads, then runs a 
separate Add kernel.
   - ON schedules the output with 16384 blocks of 1024 threads and embeds the 
entire Exp chain in that kernel. Its SASS contains 16 `MUFU.EX2` instruction 
sites.
   
   This points to repeated producer evaluation along the broadcast dimension. 
The ON cubin uses 13 registers; PARTIAL uses 11 for the producer and 10 for 
Add. All report zero stack, local and shared memory, and the disassembly 
contains no `LDL`/`STL` instructions. These results do not suggest register 
spilling. Per-kernel timings and SFU utilization still need profiling.
   
   Relevant code at the recorded commit:
   
   - 
[Fallback.apply](https://github.com/apache/tvm/blob/8f328e802cfe5e41fcc8f5c17e7582b1c28bfce4/python/tvm/s_tir/dlight/gpu/fallback.py#L66)
 calls `try_inline` before scheduling the remaining block.
   - 
[try_inline](https://github.com/apache/tvm/blob/8f328e802cfe5e41fcc8f5c17e7582b1c28bfce4/python/tvm/s_tir/dlight/base/common_schedules.py#L43)
 tries `compute_inline` and `reverse_compute_inline`.
   - 
[GraphPartitioner::RunFuse](https://github.com/apache/tvm/blob/8f328e802cfe5e41fcc8f5c17e7582b1c28bfce4/src/relax/analysis/graph_partitioner.cc#L334)
 makes the earlier fusion decision.
   
   Keeping the producer materialized reduces its output to an 8 KiB buffer and 
gives the fastest result in this test. Could the fusion or inlining decision 
account for producer cost and broadcast reuse? The PARTIAL configuration 
provides a working comparison for that change.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to