Skip to content

[Perf] aten::add.Tensor via FlagGems is slower than DAS boxing on DCU #252

Closed as not planned

Description

@wzx226226

[Performance] aten::add.Tensor via FlagGems is significantly slower than DAS boxing on DCU

Description

On Hygon DCU, aten::add.Tensor produces correct results through both the Torch-FL DAS boxing route and the mixed FlagGems route. However, the FlagGems route shows a significant per-call performance regression for the small, non-contiguous, scalar, and broadcast inputs used by ESM-2.

The regression is reproducible with a standalone synchronized microbenchmark and is also visible during a complete ESM-2 embedding inference. Further investigation with hipprof indicates that the actual FlagGems DCU addition kernel is not slow. Most of the latency is spent outside the device kernel, in metadata processing, the Python/Triton wrapper, configuration lookup, argument preparation, and the host-side kernel launch path.

Environment

  • Hardware: Hygon DCU / BW1000
  • Microbenchmark node and device: a02r4n07, physical device 0; both boxing and FlagGems tests were run on this node and device
  • End-to-end ESM-2 evidence: a02r2n06, physical device 0; this was a separate same-device A/B experiment
  • Environment activation: source /work1/share/scnethpc2601/flagos/activate.sh
  • Python: 3.10
  • PyTorch: 2.10.0+cpu
  • HIP: 6.3.26113
  • Torch-FL configuration: installed backends_dcu_flaggems.conf
  • Device visibility: HIP_VISIBLE_DEVICES=0, CUDA_VISIBLE_DEVICES=0
  • Model workload: ESM-2 8M embedding, three protein sequences, token shape (3, 101), FP32

Runtime Configuration

source /work1/share/scnethpc2601/flagos/activate.sh
export HIP_VISIBLE_DEVICES=0
export CUDA_VISIBLE_DEVICES=0
export FLAGOS_USE_FLAGGEMS=1
unset FLAGOS_BACKEND_CONFIG FLAGOS_USE_FLAGGEMS_CPP

The DAS boxing control was run in a separate Python process with FLAGOS_USE_FLAGGEMS unset. Both microbenchmark routes used the same node, physical device, input shapes, dtype, warmup count, repetition count, and synchronization method. The end-to-end ESM-2 results came from a separate same-device A/B experiment and should not be treated as the same measurement run as the standalone microbenchmark.

Minimal Performance Reproduction

The following script contains all four test cases used in the comparison. They cover the non-contiguous residual input, non-contiguous attention input, scalar addition, and one-dimensional bias broadcasting observed in ESM-2. Each case is warmed up 30 times and then measured over 200 repetitions, with an explicit device synchronization after the measured loop.

Save it as add_tensor_microbench.py:

import argparse
import time

import torch
import torch_fl


cases = {
    "residual_101x3x320_noncontig": (
        torch.randn(3, 101, 320, device="flagos:0").transpose(0, 1),
        torch.randn(101, 3, 320, device="flagos:0"),
    ),
    "attention_60x101x16_noncontig": (
        torch.randn(101, 60, 16, device="flagos:0").transpose(0, 1),
        torch.randn(60, 101, 16, device="flagos:0"),
    ),
    "ffn_101x3x1280_scalar": (
        torch.randn(101, 3, 1280, device="flagos:0"),
        1.0,
    ),
    "bias_3x101x33": (
        torch.randn(3, 101, 33, device="flagos:0"),
        torch.randn(33, device="flagos:0"),
    ),
}

parser = argparse.ArgumentParser()
parser.add_argument("--warmup", type=int, default=30)
parser.add_argument("--repeat", type=int, default=200)
args = parser.parse_args()


def run_case(left, right, warmup, repeat):
    for _ in range(warmup):
        torch.ops.aten.add.Tensor(left, right, alpha=1)
    torch.flagos.synchronize()

    started = time.perf_counter()
    for _ in range(repeat):
        output = torch.ops.aten.add.Tensor(left, right, alpha=1)
    torch.flagos.synchronize()

    elapsed_us = (time.perf_counter() - started) * 1e6 / repeat
    assert torch.isfinite(output).all().item()
    return elapsed_us


for name, (left, right) in cases.items():
    elapsed_us = run_case(left, right, args.warmup, args.repeat)
    print(f"{name}: {elapsed_us:.3f} us/call")

The same script must be run in two separate Python processes because the Torch-FL backend route is selected when torch_fl is imported. The backend should not be changed inside an already initialized process.

DAS boxing control

Run the following command with FLAGOS_USE_FLAGGEMS unset so that aten::add.Tensor uses the DAS CUDA boxing route:

ssh a02r4n07
source /work1/share/scnethpc2601/flagos/activate.sh
export HIP_VISIBLE_DEVICES=0
export CUDA_VISIBLE_DEVICES=0
unset FLAGOS_USE_FLAGGEMS
unset FLAGOS_USE_FLAGGEMS_CPP
unset FLAGOS_BACKEND_CONFIG

python -u add_tensor_microbench.py --warmup 30 --repeat 200

FlagGems test

After the boxing process exits, start a new process on the same node and physical device. Set FLAGOS_USE_FLAGGEMS=1 so that aten::add.Tensor uses the FlagGems Python/Triton route:

ssh a02r4n07
source /work1/share/scnethpc2601/flagos/activate.sh
export HIP_VISIBLE_DEVICES=0
export CUDA_VISIBLE_DEVICES=0
export FLAGOS_USE_FLAGGEMS=1
unset FLAGOS_USE_FLAGGEMS_CPP
unset FLAGOS_BACKEND_CONFIG

python -u add_tensor_microbench.py --warmup 30 --repeat 200

The inputs, test cases, warmup count, repetition count, and synchronization method are identical between the two commands. The only route-affecting difference is whether FLAGOS_USE_FLAGGEMS is set.

Microbenchmark Results

The table reports the synchronized batch-average latency per call after 30 warmup iterations and 200 measured iterations. All values are in microseconds.

Test case Input shape/layout DAS boxing FlagGems Additional latency FlagGems / boxing
Residual addition (101,3,320), first input non-contiguous 15.497 µs 309.517 µs +294.020 µs 19.97x
Attention addition (60,101,16), first input non-contiguous 15.481 µs 305.277 µs +289.796 µs 19.72x
FFN scalar addition (101,3,1280) + 1.0 18.132 µs 336.548 µs +318.416 µs 18.56x
Bias broadcast addition (3,101,33) + (33,) 15.449 µs 292.517 µs +277.068 µs 18.93x

All four test cases produced finite outputs. The reported interval contains 200 operator calls followed by one device synchronization, divided by 200. This avoids creating and recording a separate event pair for every individual operator call. Both routes completed successfully with exit code 0.

End-to-End ESM-2 Evidence

During one complete ESM-2 embedding forward pass, aten::add.Tensor is executed 33 times. The following table shows the aggregated per-overload measurements from that forward pass:

Route Total latency for 33 calls Average latency per call
DAS boxing 1680.9 µs 50.94 µs
FlagGems 11846.0 µs 358.97 µs
Additional FlagGems latency 10165.1 µs 308.03 µs
FlagGems / boxing 7.047x 7.047x

Overriding only aten::add.Tensor to use boxing reduced the ESM-2 embedding median end-to-end latency from 42.663 ms to 33.520 ms, an improvement of approximately 9.143 ms. The FlagGems and boxing outputs passed the numerical comparison with rtol=1e-4 and atol=1e-5, so the performance difference is not caused by divergent results.

Internal Stage Investigation

For the representative non-contiguous residual input (101,3,320), the following steady-state timings were collected after warmup from the FlagGems implementation:

FlagGems stage Latency Work performed
pointwise_dynamic.prepare_args, corresponding to op.meta() approximately 93 µs Tensor/scalar validation, dtype promotion, broadcast-shape calculation, non-contiguous stride analysis, output preparation, and StridedBuffer construction
instantiate approximately 2.7 µs Lookup of an already generated overload; this rules out repeated Triton compilation as the main cause
Generated Triton overload, within op.impl() approximately 166 µs Triton wrapper execution, configuration-key and cache lookup, grid calculation, argument packing, queue submission, and kernel launch
Actual add_func_kernel_rank_3 approximately 4.374 µs Device-side elementwise addition measured with hipprof

Relevant source locations in the repository are:

  • FlagGems/src/flag_gems/utils/pointwise_dynamic.py
  • FlagGems/src/flag_gems/utils/libentry.py
  • FlagGems/src/flag_gems/ops/add.py

The actual DCU kernel takes only approximately 4.374 µs, while the complete FlagGems aten::add.Tensor call takes approximately 358.97 µs in the ESM-2 operator profile. This indicates that the regression is dominated by fixed host-side work outside the arithmetic kernel rather than by insufficient device-side addition throughput.

Expected Behavior

For small elementwise additions, the FlagGems route should provide steady-state latency comparable to DAS boxing, or the backend should provide a general mechanism that avoids paying the full Python/Triton dispatch cost for every invocation. In the current measurements, the actual kernel takes approximately 4.374 µs, whereas the complete ATen call takes approximately 358.97 µs, indicating excessive host-side overhead.

Possible Backend Fix Directions

  1. Cache op.meta() results for repeated dtype, shape, stride, and layout signatures, including broadcasted shapes, strides, and output metadata.
  2. Cache complete launch metadata for generated overloads, including the configuration key, grid, and argument descriptors, instead of rebuilding it on every invocation.
  3. Reduce repeated work in the op.impl() Python wrapper and the C++/Python bridge; consider a backend C++ fast path for common binary elementwise operators.
  4. Add a replay benchmarker for the DCU backend, or skip unnecessary event-timing autotuning after the configuration for a signature is already known.

These are intended as Torch-FL/FlagGems backend changes. Reproducing or addressing this issue should not require users to modify model code.

Regression Test Proposal

A DCU performance regression test could cover the four representative cases above and perform the following checks:

  • Verify that both boxing and FlagGems outputs contain only finite values.
  • Compare numerical results between the two routes with an explicit tolerance.
  • Record synchronized per-call latency using identical warmup and repetition counts.
  • Report or fail when the FlagGems/boxing ratio exceeds a project-defined threshold.

The performance threshold should remain configurable per hardware platform. On the tested DCU system, a ratio above 2x could initially be treated as a regression signal rather than as a universal fixed threshold.

Checklist

  • Compared DAS boxing and FlagGems on the same node and physical device
  • Included a self-contained minimal performance reproduction
  • Covered four input patterns observed in a real workload
  • Included end-to-end ESM-2 evidence
  • Included ATen-level, FlagGems internal-stage, and hipprof kernel evidence
  • Verified finite outputs and numerical consistency
  • Suggested backend-level fix directions and a regression test

Activity

  1. lvyufeng commented on Sep 15, 2026

    @lvyufeng
    Collaborator

    标题?

  2. wzx226226 commented on Sep 15, 2026

    @wzx226226
    Author
  3. changed the title [-][BUG][/-] [+][Perf] aten::add.Tensor via FlagGems is slower than DAS boxing on DCU[/+] on Sep 26, 2026
  4. lvyufeng commented on Sep 28, 2026

    @lvyufeng
    Collaborator

    Re-measured on current main. The regression still reproduces when the FlagGems route is forced, but the shipped DCU config no longer takes that route, so this is no longer reachable in a stock install. Details and the numbers below.

    What changed since the report

    Two things in the reproduction commands no longer hold on main:

    1. FLAGOS_USE_FLAGGEMS does not select anything on DCU any more. The per-op override is now FLAGOS_OP_<op>=<backend> with dots doubled (torch_fl/_env.py, LoadBackendConfig in csrc/aten/common.cc), e.g. FLAGOS_OP_add__Tensor=flaggems. The issue's two commands (FLAGOS_USE_FLAGGEMS unset vs set) now run the same route, so the A/B has to be expressed as an explicit override.

    2. add.Tensor is pinned to cuda in backends_dcu.conf. PR fix: repair DCU SDPA backend selection and pointwise routing for Qwen-Image #345 moved eleven pointwise overloads to boxing as part of the Qwen-Image-2512 work; add.Tensor is one of them (torch_fl/configs/backends_dcu.conf:769). The pin is for an independent correctness reason — with a bf16 tensor beside a Python float, ATen's wrapped number puts an f64 into FlagGems' pointwise kernel and the HCU backend aborts in TruncFOpConversion::createDestOps (FlagGems #6212) — not because of this perf issue. The effect on this issue is that the FlagGems arm is now reachable only by overriding that one entry, and a user running the installed conf gets boxing.

    Method

    The two arms are therefore

    arm selector
    boxing FLAGOS_OP_add__Tensor=cuda (what the shipped conf routes)
    flaggems FLAGOS_OP_add__Tensor=flaggems (what #252 measured)

    The four cases, warmup, repetition count, synchronization and input shapes are the issue's, unchanged. Two deliberate deviations from the issue's script:

    • One process per (case, route). The route is resolved from the conf when torch_fl loads it, so the two arms cannot share an interpreter; the issue says this. A case's reading also moves by more than an order of magnitude when it shares an interpreter with the cases before it, so the per-case process boundary is what makes the four numbers comparable rather than a nicety.
    • Inputs are built on the host and moved to flagos:0, not created on the device. A device torch.randn is itself routed through the conf, so it is a different measurement under each arm.

    The route each arm actually took was confirmed with FLAGOS_LOG=dispatch, not inferred from the selector:

    [flagos dispatch] add.Tensor -> flagos_python
    

    with the executing frame at flag_gems/ops/add.py:107 in add — i.e. the FlagGems Python/Triton path the issue is about, on the arm that is supposed to reach it.

    Result

    Two consecutive runs, flagos:0, warmup 30 / repeat 200, synchronized batch average:

    case boxing µs/call (run 1 / run 2) FlagGems µs/call (run 1 / run 2) ratio (run 1 / run 2)
    residual_101x3x320_noncontig 14.663 / 14.684 272.696 / 273.239 18.60x / 18.61x
    attention_60x101x16_noncontig 14.474 / 13.995 275.136 / 269.080 19.01x / 19.23x
    ffn_101x3x1280_scalar 15.357 / 15.673 203.016 / 204.989 13.22x / 13.08x
    bias_3x101x33 13.487 / 14.016 265.998 / 263.096 19.72x / 18.77x

    All four cases produced finite outputs on both arms, and both arms exit 0.

    The regression is unchanged in shape: three of four cases reproduce at 18.6x–19.7x against the report's 18.93x–19.97x, and the ordering is the same apart from the scalar case. Two differences worth recording rather than smoothing over:

    • Absolute latency is lower on both arms here than in the report (boxing 13.5–15.7 µs vs 15.4–18.1 µs; FlagGems 203–275 µs vs 292–337 µs). Different node and a different FlagGems build; the ratio is the quantity that carries over, not the absolutes.
    • The scalar case moved from the report's worst ratio (18.56x, 336.5 µs) to the best here (13.1x, 204 µs). That is a real attenuation on the current FlagGems build for that one case, not scatter — it reproduces across both runs to within 0.15x. It is still 6.5x the 2x threshold the issue proposes.

    Environment

    Provenance gap, stated rather than glossed: this run is in a container whose hostname is 98c077ef0619, not on the report's a02r4n07 / a02r2n06. The A/B is same-node, same-device, same-process-boundary discipline within this run, so the ratio is comparable; the absolute numbers are not a reproduction on the report's hardware. If a same-node comparison against a02r4n07 matters, the harness below runs as-is there.

    Where the residual lives

    Unchanged from the report's own investigation: the device kernel is a few microseconds and the rest is host-side — pointwise_dynamic.prepare_args, the generated wrapper, config/dtype/stride metadata and launch preparation, per call, with no signature-keyed caching of the prepared metadata. Nothing in Torch-FL changed that, and the fix directions the issue lists (cache op.meta() by layout signature, cache launch descriptors, a C++ fast path for common binary elementwise ops) are all FlagGems-side. That work is tracked upstream by FlagGems #6570 ([Performance][DCU] FlagGems pointwise_dynamic adds high steady-state latency to aten::add.Tensor, the same report, open) and by the generic root cause in FlagGems #4812 (pointwise_dynamic/libentry per-call CPU dispatch overhead dominates small-tensor latency).

    Disposition

    Closing this: the shipped DCU configuration routes add.Tensor to boxing for an independent correctness reason, so no installed DCU user reaches the slow path and the "Expected Behavior" section's framing ("or the backend should provide a general mechanism that avoids paying the full Python/Triton dispatch cost") is now the whole of the remaining ask — and it belongs to FlagGems, where it is filed. Please reopen if the cuda pin is ever lifted for add.Tensor, or if a DCU conf is shipped that routes it to FlagGems again; the tables above are the baseline to compare against. The 2x regression threshold the issue proposes is not implemented as a test — a threshold test whose arms both need a DCU runner and whose expected ratio is currently 19x would be red on arrival, and this repo measures routes on demand rather than in CI (tests/manual/flaggems_overload_survey.py).

    Harness used for the two runs

    tests/manual/dcu_add_tensor_ab.py in style (one subprocess per case/route, like the existing dcu_pointwise_replay.py):

    import argparse
    import json
    import os
    import subprocess
    import sys
    from pathlib import Path
    
    CHILD = r"""
    import json
    import sys
    import time
    
    import torch_fl  # noqa: F401  - must precede `import torch`
    import torch
    
    name = sys.argv[1]
    device = torch.device(sys.argv[2])
    warmup = int(sys.argv[3])
    repeat = int(sys.argv[4])
    
    # Built on the host and moved, rather than created on the device: a device
    # `torch.randn` is itself routed through the conf (to flag_gems.ops.randn under
    # the FlagGems arm), which is a different measurement.
    CASES = {
        "residual_101x3x320_noncontig": lambda: (
            torch.randn(3, 101, 320).to(device).transpose(0, 1),
            torch.randn(101, 3, 320).to(device),
        ),
        "attention_60x101x16_noncontig": lambda: (
            torch.randn(101, 60, 16).to(device).transpose(0, 1),
            torch.randn(60, 101, 16).to(device),
        ),
        "ffn_101x3x1280_scalar": lambda: (
            torch.randn(101, 3, 1280).to(device),
            1.0,
        ),
        "bias_3x101x33": lambda: (
            torch.randn(3, 101, 33).to(device),
            torch.randn(33).to(device),
        ),
    }
    
    left, right = CASES[name]()
    
    for _ in range(warmup):
        output = torch.ops.aten.add.Tensor(left, right, alpha=1)
    torch_fl.flagos.synchronize(device)
    
    started = time.perf_counter()
    for _ in range(repeat):
        output = torch.ops.aten.add.Tensor(left, right, alpha=1)
    torch_fl.flagos.synchronize(device)
    elapsed_us = (time.perf_counter() - started) * 1e6 / repeat
    
    assert torch.isfinite(output).all().item()
    print("RESULT " + json.dumps(elapsed_us))
    """
    
    CASES = (
        "residual_101x3x320_noncontig",
        "attention_60x101x16_noncontig",
        "ffn_101x3x1280_scalar",
        "bias_3x101x33",
    )
    
    ROUTES = {"boxing": "cuda", "flaggems": "flaggems"}
    
    
    def run_once(route, case, device, warmup, repeat, timeout):
        env = dict(os.environ)
        env["FLAGOS_OP_add__Tensor"] = ROUTES[route]
        child = Path("/tmp") / f"dcu_add_tensor_ab_{route}_{case}.py"
        child.write_text(CHILD, encoding="utf-8")
        proc = subprocess.run(
            [sys.executable, str(child), case, device, str(warmup), str(repeat)],
            capture_output=True,
            text=True,
            env=env,
            timeout=timeout,
            check=False,
        )
        out = proc.stdout + proc.stderr
        for line in out.splitlines():
            if line.startswith("RESULT "):
                return json.loads(line[len("RESULT ") :])
        raise SystemExit(f"{route}/{case} produced no measurement (exit {proc.returncode}):\n{out}")
    
    
    def main():
        parser = argparse.ArgumentParser()
        parser.add_argument("--device", default="flagos:0")
        parser.add_argument("--warmup", type=int, default=30)
        parser.add_argument("--repeat", type=int, default=200)
        parser.add_argument("--timeout", type=int, default=900)
        args = parser.parse_args()
    
        print(f"device: {args.device}   warmup: {args.warmup}   repeat: {args.repeat}")
        print(f"{'case':32s} {'boxing us/call':>14s} {'flaggems us/call':>16s} {'ratio':>7s}")
        for case in CASES:
            boxing = run_once("boxing", case, args.device, args.warmup, args.repeat, args.timeout)
            flaggems = run_once("flaggems", case, args.device, args.warmup, args.repeat, args.timeout)
            print(f"{case:32s} {boxing:14.3f} {flaggems:16.3f} {flaggems / boxing:6.2f}x")
        return 0
    
    
    if __name__ == "__main__":
        sys.exit(main())

    The one environment note for running it on the shared install: keep /usr/local/lib and /usr/local/lib64 off LD_LIBRARY_PATH so the checkout's libtorch_fl is the one that loads, and set FLAGOS_ACCELERATOR=dcu plus the DTK roots.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions