ml-explore / ml-explore/mlx

`nn.Upsample(mode="linear")` is 2-4x slower than a matmul-based bilinear resize when upsampling

Open
#4,435 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

performance
Dominant language
C++
Stars
28.5k
Forks
2.3k
Avg merge
3d 8h
Merged PRs (30d)
62

Description

Description

mx.nn.Upsample with mode="linear"/align_corners=False is significantly
slower than it needs to be specifically when upsampling (scale_factor > 1).
I found this while porting a video frame-interpolation model (RIFE) to MLX,
where repeated bilinear resizes are a meaningful fraction of the forward pass.

A fixed-ratio bilinear resize is separable into two 1D linear resizes, each of
which is just a matrix multiply by a small, fixed (2-nonzeros-per-row) weight
matrix — turning "resize" into "matmul" plays to MLX's own strength here. This
is bit-exact with nn.Upsample's output (see repro) and 2-4x faster for
upsampling shapes; for downsampling nn.Upsample is actually a bit faster
than the matmul version, so this looks like a real, specific gap in the
upsampling path rather than a case for just always avoiding nn.Upsample.

Repro
"""Repro: mx.nn.Upsample(mode="linear") is 4-8x slower than a
fixed-ratio bilinear resize implemented as two small matmuls, at
ordinary image-pipeline resize shapes. No torch involved - MLX only.
"""
import time
import numpy as np
import mlx.core as mx
import mlx.nn as nn


def matmul_resize_weight(in_size, out_size):
    scale = in_size / out_size
    src = (np.arange(out_size) + 0.5) * scale - 0.5
    src = np.clip(src, 0, in_size - 1)
    x0 = np.floor(src).astype(np.int64)
    x1 = np.minimum(x0 + 1, in_size - 1)
    w1 = (src - x0).astype(np.float32)
    w0 = 1.0 - w1
    w = np.zeros((out_size, in_size), dtype=np.float32)
    w[np.arange(out_size), x0] += w0
    w[np.arange(out_size), x1] += w1
    return mx.array(w)


def matmul_resize(x, scale_factor):
    n, h, w, c = x.shape
    out_h, out_w = round(h * scale_factor), round(w * scale_factor)
    wh, ww = matmul_resize_weight(h, out_h), matmul_resize_weight(w, out_w)
    x = (wh @ x.transpose(1, 0, 2, 3).reshape(h, n * w * c)).reshape(out_h, n, w, c).transpose(1, 0, 2, 3)
    x = (ww @ x.transpose(2, 0, 1, 3).reshape(w, n * out_h * c)).reshape(out_w, n, out_h, c).transpose(1, 2, 0, 3)
    return x


def timed(fn, n=30, warmup=8):
    for _ in range(warmup):
        mx.eval(fn())
    t0 = time.perf_counter()
    for _ in range(n):
        mx.eval(fn())
    return (time.perf_counter() - t0) / n * 1000


for in_h, in_w, scale in [(192, 320, 4.0), (384, 640, 2.0), (768, 1280, 0.25), (768, 1280, 0.5)]:
    x = mx.random.normal((1, in_h, in_w, 4))
    up = nn.Upsample(scale_factor=scale, mode="linear", align_corners=False)
    up_c = mx.compile(lambda a: up(a))
    resize_c = mx.compile(lambda a: matmul_resize(a, scale))

    up_ms = timed(lambda: up_c(x))
    resize_ms = timed(lambda: resize_c(x))
    diff = float(mx.abs(up_c(x) - resize_c(x)).max())
    out_h, out_w = round(in_h * scale), round(in_w * scale)
    print(f"{in_h}x{in_w} -> {out_h}x{out_w} (scale={scale}): "
          f"nn.Upsample={up_ms:.3f}ms  matmul_resize={resize_ms:.3f}ms  "
          f"({up_ms/resize_ms:.1f}x slower)  max_diff={diff:.2e}")
Output (M1 Pro, mlx 0.31.2, macOS 26.4.1)
192x320 -> 768x1280 (scale=4.0): nn.Upsample=4.008ms  matmul_resize=0.979ms  (4.1x slower)  max_diff=4.77e-07
384x640 -> 768x1280 (scale=2.0): nn.Upsample=3.937ms  matmul_resize=1.886ms  (2.1x slower)  max_diff=4.77e-07
768x1280 -> 192x320 (scale=0.25): nn.Upsample=0.509ms  matmul_resize=0.863ms  (0.6x slower)
768x1280 -> 384x640 (scale=0.5): nn.Upsample=1.180ms  matmul_resize=1.659ms  (0.7x slower)

(Both compiled with mx.compile; same pattern holds eager, just uniformly
slower on both sides.)

Context

This showed up as a real end-to-end cost: in a RIFE (video frame
interpolation) port, replacing nn.Upsample with this matmul-based resize
cut ~18ms off a ~354ms forward pass at 768x1280 purely from the upsample
calls (each block does one upsample of its flow/mask output back to full
resolution). Happy to open a PR with the matmul-based implementation
(resize()/_weight_matrix() in the repro) if that's a fix you'd want,
or this may point to something fixable at a lower level in however
upsample_linear currently walks the output for scale_factor > 1.

For context on where MLX is already doing well on this same machine (so
this issue is a targeted gap, not a general performance complaint): a
native mx.nn/mx.core GPT (minGPT's gpt-mini config, 2.71M params),
compiled with mx.compile's stateful pattern
(mx.compile(step, inputs=state, outputs=state)), trains 1.76x faster
than the same architecture on PyTorch+MPS (88.7ms/step vs. 154.9ms/step)

  • consistent with transformer_lm's existing example using the same
    compile pattern. The Upsample gap above is the one place in two separate
    model ports (this GPT, and the RIFE port) where MLX measured slower than
    PyTorch on identical work.
Environment
  • mlx 0.31.2
  • macOS 26.4.1, Apple M1 Pro

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at the nn.Upsample entry point and the upsample_linear path mentioned in the issue; use the supplied benchmark to compare scale_factor > 1 against downsampling and verify numerical equivalence. Done means the upsampling path closes the reported performance gap without changing the output.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
machine-learning, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.