Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 73 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ integration tests. Matrix: macOS **arm64** (`macos-15`), macOS **x64**

All three `download-deps-*` scripts apply these automatically, and they must stay
**identical across platforms** — a patch applied on only some platforms makes the
same job produce different output per OS. Six patches:
same job produce different output per OS. Seven patches:

1. **mvtools API**: Renamed `_lambda`→`lambda`, `_global`→`global` parameters
2. **DFTTest API**: `sstring` parameter removed, replaced with `sigma=10.0`
Expand All @@ -893,6 +893,8 @@ same job produce different output per OS. Six patches:
over; fall back to CPU `EEDI3` (NNEDI3CL still uses the GPU)
5. **`Bob()` 16-bit resample** — see below
6. **ARM nnedi3 preference** — see "The ARM interpolator choice" below
7. **akarin Expr routing** — rebinds havsfunc's `core` to a proxy that sends
`.std.Expr` to `akarin.Expr`; see "`std.Expr` on ARM" below

Each patch is a **literal string match** against havsfunc r31. If upstream ever
changes one of those lines the patch silently does nothing, so behavioural tests
Expand Down Expand Up @@ -958,9 +960,75 @@ path also needs `yasm`, which the runners don't have).
> `ExprInterpreter::eval()`, a scalar switch-dispatch interpreter run **once per
> pixel**: 69.5s vs 3.3s of CPU on the same job, **21x**. Most of VS core is
> x86-SIMD-only the same way (genericfilters, mergefilters, averageframes,
> planestats). Fixing that needs a vectorized Expr on ARM and is tracked
> separately. Not a cause: mvtools is *faster* natively (it compiles its SSE2
> paths through simde).
> planestats). Not a cause: mvtools is *faster* natively (it compiles its SSE2
> paths through simde). **That Expr gap is now closed by akarin** — see below.

### `std.Expr` on ARM goes through akarin's LLVM JIT

VapourSynth's `compile_jit()` is x86-only, so on ARM every expression is walked
**once per pixel** by `ExprInterpreter::eval()`. Measured on an M1 under R78,
`Expr` costs **550–640 CPU-seconds** in a QTGMC Slow graph against the
interpolator's **30** — it is not one cost among several, it is the cost.
`akarin.Expr` is a real LLVM JIT that works on aarch64: **QTGMC Slow 11.5s →
2.8s, 4.1x** (3 runs each, 720x576, 120 output frames).

Three things to keep straight:

- **The routing is a shim, not 116 edits.** havsfunc has 116 `core.std.Expr`
call sites, so **patch 7** rebinds its module-level `core` to a proxy that
swaps *only* `.std.Expr` and forwards everything else. The proxy installs
**only when `core.akarin` exists**, so where it doesn't, `core` stays the real
core — no wrapper, no overhead, no behaviour change. Both templates get an
`_expr()` helper for their own two call sites each, mirroring `_nnedi3()`;
`test_93` fails the build if either calls `core.std.Expr` directly, and also
if the helper's fallback calls *itself* (a blanket search-and-replace made it
infinitely recursive once — the fallback must name `core.std.Expr`).
- **macOS x64 deliberately does not get it.** The only wheel is
`macosx_14_0_x86_64` and that bundle targets **12.0** (issue #39), so shipping
it would raise the Intel floor to macOS 14 — for a platform that already has
the JIT. It keeps `std.Expr` through the fallback. The namespace requirement in
`vapoursynth_integration_test.dart` is therefore **conditional**, like `nnedi3`.
- **It is not bit-identical, and the one difference is known.** Of the **46**
expressions havsfunc actually generates, 45 match exactly. The exception is the
`DeHalo_alpha`/`FineDehalo` edge-**mask** scale `x {thmi} - {i} / 255 *`, where
one input value lands on an exact `.5` tie: `std.Expr` rounds half-to-even,
akarin rounds down — one level, in a mask. So macOS x64 differs from every
other platform by that one level. That is far smaller than the ARM/x86
difference already accepted for nnedi3 vs znedi3 (mean 0.045/255, worst pixel
27/255).

**Test the corpus, not a sample.** The expressions are mostly f-strings with
computed thresholds, so a static scan of havsfunc finds **11** of the 46.
`app/test/akarin_expr_parity_test.dart` (heavy) collects them *at runtime* across
every QTGMC preset plus daa/santiag/LSFmod/DeHalo_alpha/FineDehalo/SMDegrain/
Deblock_QED/EdgeCleaner/YAHR, then compares both implementations over inputs
covering all 256 values. It asserts `corpus >= 40` so a broken collector fails
rather than passing vacuously, and bounds the difference at exactly what is
measured today (worst ≤ 1 level, ≤ 1 differing expression).

The plugin comes from the `vapoursynth-akarin` **PyPI wheel** (a wheel is a zip;
never pip-install it into the embedded interpreter), pinned to a version and
resolved through the PyPI JSON API so the hashed file URL is never hardcoded.
Per-platform placement differs and matters:

| | plugin | its private libs |
|---|---|---|
| macOS arm64 | `vapoursynth/plugins/` | `lib/`, repointed from `@loader_path/../../../vapoursynth_akarin.dylibs` and **re-signed** |
| Linux x64/arm64 | `vapoursynth/plugins/` | `lib/`, via `patchelf --set-rpath` |
| Windows x64 | `vapoursynth/vs-plugins/` | **beside the plugin** |

On Unix the private libs go in `lib/` rather than `plugins/`, because `plugins/`
is autoloaded and a non-plugin `.so` there gets probed on every core init. `lib/`
is deliberately **not** on `DYLD_LIBRARY_PATH`, so the bundled libz cannot shadow
the system one for ffmpeg. Linux's zstd carries a **per-arch build hash** in its
filename (`libzstd-5df4f4df…` on x64, `-a1561916…` on arm64), so glob it — and
the ELF `NEEDED` entry uses that exact hashed name.

akarin is **LGPL-3.0** and statically links **LLVM 22.1.2** (Apache-2.0 with LLVM
exception); both are in `licenses/NOTICES.txt`. It adds ~61 MB uncompressed per
platform, but only about **21 MB to each deps zip** — the earlier "the zips
roughly double" estimate was wrong, because it compared uncompressed size against
compressed zips.
### Linux builds on ubuntu-24.04, and that sets the glibc floor

The Linux **deps** builds and the runners that test against them
Expand Down Expand Up @@ -1473,3 +1541,4 @@ Create the app-specific password at appleid.apple.com → Sign-In and Security
| 1.0.0 | 2025-01-15 | Initial release |
| … | | (1.1.0–1.6.0 went unrecorded) |
| 1.7.0 | 2026-08-01 | Fixes QTGMC Placebo/Very Slow brightening and near-black Draft on arm64, via `Scripts/patches/fmtconv-r31-arm-int-scaler.patch` (root cause: sign constants in fmtconv's non-SIMD integer scaler) plus havsfunc patch 5 as defence in depth; fmtconv r30 → **r31**, now pinned and sourced from GitLab on every platform. **Rebuilt 2026-08-02** to add the **zsmooth** plugin (MIT), providing `core.zsmooth.CCD` plus `Cnr4` and a set of RemoveGrain/TemporalMedian-family filters. Version pinned to 0.19.0 in all three download scripts — keep them in step so the same job can't produce different chroma per OS. Taken pre-built everywhere except macOS x64, which builds it with Zig to reach `minos 12.0` (see the macOS platform notes) |
| 1.8.0 | 2026-08-07 | VapourSynth **R73 → R78** on every platform, which moves Windows to a Python 3.12 wheel layout and makes `deps/<platform>/vapoursynth/` the Python package itself on macOS/Linux (see the R78 sections). Adds the **akarin** plugin (LGPL-3.0, statically links LLVM 22.1.2) supplying an LLVM JIT for `std.Expr`, routed in via havsfunc **patch 7** and the templates' `_expr()` helper — worth **4.1x** on arm64 QTGMC Slow, since VapourSynth's own Expr JIT is x86-only. **Not** shipped on macos-x64, whose only wheel would raise the Intel floor to macOS 14 (issue #39). Fixes the **nnedi3** build on linux-arm64, which had never produced a binary (`-mfpu=neon` and `HWCAP_ARM_*` are both 32-bit-ARM-only), and drops the plugin from linux-x64's expected list to match the other x86 bundles. **BestSource removed** — nothing had called it since the pipe source replaced it. Linux now needs **glibc 2.39** (ubuntu-24.04), so Ubuntu 22.04 and Debian 12 can no longer run it |
13 changes: 9 additions & 4 deletions Scripts/deps-expected-plugins.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"libmvtools.dll",
"libtemporalmedian.dll",
"neo-f3kdb.dll",
"vsznedi3.dll"
"vsznedi3.dll",
"libakarin.dll",
"libzstd.dll"
],
"macos-arm64": [
"libaddgrain.dylib",
Expand All @@ -50,7 +52,8 @@
"libttempsmooth.dylib",
"libvivtc.dylib",
"libznedi3.dylib",
"libzsmooth.dylib"
"libzsmooth.dylib",
"libakarin.dylib"
],
"macos-x64": [
"libaddgrain.dylib",
Expand Down Expand Up @@ -100,7 +103,8 @@
"libttempsmooth.so",
"libvivtc.so",
"libznedi3.so",
"libzsmooth.so"
"libzsmooth.so",
"libakarin.so"
],
"linux-arm64": [
"libaddgrain.so",
Expand All @@ -126,6 +130,7 @@
"libttempsmooth.so",
"libvivtc.so",
"libznedi3.so",
"libzsmooth.so"
"libzsmooth.so",
"libakarin.so"
]
}
112 changes: 112 additions & 0 deletions Scripts/download-deps-linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,69 @@ else
echo " DeScratch already exists, skipping"
fi

# ============================================================================
# akarin — LLVM JIT for std.Expr
# ============================================================================
# VapourSynth's own Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM
# every expression is walked once per pixel by a scalar interpreter. akarin has
# a real LLVM JIT: measured 4.1x end to end on QTGMC Slow (arm64), and
# bit-identical to std.Expr on 45 of the 46 expressions havsfunc generates.
#
# Shipped on both Linux arches. x86 gains little (it already has a JIT) but the
# wheel exists, the routing shim is arch-neutral, and keeping the two arches
# identical avoids the same job producing different output per platform.
# manylinux_2_35 is satisfied by our glibc 2.39 floor (ubuntu-24.04).
AKARIN_VERSION="1.4.1"
echo ""
echo "=== Downloading akarin $AKARIN_VERSION (LLVM JIT for std.Expr) ==="
if [ "$FORCE" = true ] || [ ! -f "$PLUGINS_DIR/libakarin.so" ]; then
case "$ARCH" in
x86_64) AK_TAG="manylinux_2_35_x86_64" ;;
aarch64) AK_TAG="manylinux_2_35_aarch64" ;;
*) AK_TAG="" ;;
esac
if [ -z "$AK_TAG" ]; then
echo " No akarin wheel for $ARCH - skipping (std.Expr fallback applies)."
else
AK_URL=$("$PYTHON_BIN" - "$AKARIN_VERSION" "$AK_TAG" <<'PYEOF'
import json, sys, urllib.request
ver, tag = sys.argv[1], sys.argv[2]
d = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/vapoursynth-akarin/{ver}/json"))
print(next(f["url"] for f in d["urls"] if f["filename"].endswith(tag + ".whl")))
PYEOF
)
rm -rf "$BUILD_DIR/akarin" && mkdir -p "$BUILD_DIR/akarin" "$LIB_DIR"
curl -L -o "$BUILD_DIR/akarin.whl" "$AK_URL"
# A wheel is a zip; take the plugin binary only, never pip install into
# the embedded interpreter.
unzip -q "$BUILD_DIR/akarin.whl" -d "$BUILD_DIR/akarin"
cp "$BUILD_DIR/akarin/vapoursynth/plugins/akarin/libakarin.so" "$PLUGINS_DIR/"
# The private zstd carries a build hash in its filename that differs per
# arch (libzstd-5df4f4df... on x64, -a1561916... on arm64), so glob it.
# It goes in lib/, not plugins/, because plugins/ is autoloaded and a
# non-plugin .so there would be probed on every core init.
cp "$BUILD_DIR/akarin"/vapoursynth_akarin.libs/*.so* "$LIB_DIR/" 2>/dev/null || true
chmod u+w "$PLUGINS_DIR/libakarin.so"
patchelf --set-rpath '$ORIGIN:$ORIGIN/../../lib' "$PLUGINS_DIR/libakarin.so" 2>/dev/null || true

# The wheel's RPATH points at vapoursynth_akarin.libs, which does not
# exist in our layout; if a NEEDED entry is still unresolvable the
# plugin loads here and dies in the packaged bundle, so check now.
if command -v ldd >/dev/null 2>&1; then
if ldd "$PLUGINS_DIR/libakarin.so" 2>/dev/null | grep -q "not found"; then
echo " ERROR: libakarin.so has unresolved dependencies after relinking:"
ldd "$PLUGINS_DIR/libakarin.so" | grep "not found" | sed 's/^/ /'
exit 1
fi
fi
rm -rf "$BUILD_DIR/akarin" "$BUILD_DIR/akarin.whl"
BUILT_PLUGINS+=("akarin")
echo " Installed akarin -> libakarin.so"
fi
else
echo " akarin already exists, skipping"
fi

# ============================================================================
# Download NNEDI3 weights
# ============================================================================
Expand Down Expand Up @@ -1169,6 +1232,55 @@ def _nnedi3_impl():
''')
patches.append(f'ARM nnedi3 preference ({n_edi} sites)')

# Patch 7: route std.Expr through akarin's LLVM JIT.
# VapourSynth's Expr JIT is wrapped in #ifdef VS_TARGET_CPU_X86, so on ARM the
# whole bytecode program is walked once per pixel by ExprInterpreter::eval().
# Expr dominates arm64 QTGMC by a wide margin -- measured 550-640 CPU-seconds
# against the interpolator's 30 -- so this is the single biggest arm64 win
# available: 4.1x end to end on QTGMC Slow (11.5s -> 2.8s, M1, 720x576).
#
# havsfunc has 116 core.std.Expr call sites. Rewriting each one is unmaintainable
# against a file we already patch six other ways, so rebind the module's `core`
# to a proxy that swaps only .std.Expr and forwards everything else untouched.
#
# Verified against the 46 distinct expressions havsfunc actually generates
# (collected at runtime across every QTGMC preset plus daa/santiag/LSFmod/
# DeHalo_alpha/FineDehalo/SMDegrain/Deblock_QED/EdgeCleaner/YAHR): 45 are
# bit-identical. The one exception is the DeHalo_alpha/FineDehalo edge-MASK
# scale 'x {thmi} - {i} / 255 *', where a single input value lands on an exact
# .5 tie -- std.Expr rounds half-to-even, akarin rounds it down, so one level in
# a mask. macOS x64 has no akarin wheel compatible with our 12.0 floor and so
# keeps std.Expr; that platform therefore differs by that one level. It is far
# smaller than the ARM/x86 difference already accepted for nnedi3 vs znedi3
# (mean 0.045/255, worst pixel 27/255).
#
# The shim installs only when akarin is present, so where it is absent `core`
# stays the real core: no wrapper, no overhead, no behaviour change.
if '_akarin_expr' not in content:
content = content.replace('import math\n', 'import math\n' + '''

# Route std.Expr through akarin's LLVM JIT where present (see download-deps-*).
_akarin_expr = getattr(getattr(core, 'akarin', None), 'Expr', None)
if _akarin_expr is not None:
class _ExprStd:
__slots__ = ('_std',)
def __init__(self, std):
self._std = std
def __getattr__(self, name):
return _akarin_expr if name == 'Expr' else getattr(self._std, name)

class _ExprCore:
__slots__ = ('_core', '_std')
def __init__(self, c):
self._core = c
self._std = _ExprStd(c.std)
def __getattr__(self, name):
return self._std if name == 'std' else getattr(self._core, name)

core = _ExprCore(core)
''')
patches.append('akarin Expr routing')

if patches:
with open(havsfunc_path, 'w') as f:
f.write(content)
Expand Down
Loading
Loading