perf(arm64): route std.Expr through akarin's LLVM JIT - #62
Merged
Conversation
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().
Measured on an M1 against the R78 bundle, Expr costs 550-640 CPU-seconds in a
QTGMC Slow graph against the interpolator's 30 -- it is the dominant arm64 cost
by a wide margin, and the reason native arm64 has trailed the x64 bundle under
Rosetta.
akarin has a real LLVM JIT that works on aarch64. End to end on QTGMC Slow:
11.5s -> 2.8s, a 4.1x speedup (3 runs each, 720x576, 120 output frames).
Phase A -- acquisition. Each download-deps-* script extracts the plugin from the
vapoursynth-akarin PyPI wheel (a wheel is a zip; no pip install into the
embedded interpreter). Version pinned to 1.4.1 and resolved through the PyPI
JSON API, so the hashed file URL is never hardcoded.
- macOS: arm64 only. The x64 wheel is macosx_14_0 while that bundle targets
12.0 (issue #39), so shipping it would raise the Intel floor to macOS 14 --
and x86 already has the JIT, so it loses nothing. The wheel links its private
libs as @loader_path/../../../vapoursynth_akarin.dylibs, which resolves to
nothing in our layout, so they are repointed to lib/ (the nnedi3cl -> boost
convention) and re-signed. lib/ is deliberately not on DYLD_LIBRARY_PATH, so
the bundled libz cannot shadow the system one for ffmpeg.
- Linux: both arches. The private zstd carries a per-arch build hash in its
filename, so it is globbed rather than named, and goes in lib/ -- not
plugins/, which is autoloaded and would probe it on every core init.
- Windows: libzstd.dll sits beside the plugin, which is how the wheel ships it
and how Windows resolves a plugin's own dependencies.
Phase B -- routing. havsfunc has 116 core.std.Expr call sites; rewriting each is
unmaintainable against a file already patched six other ways. Patch 7 instead
rebinds the module's `core` to a proxy that swaps only .std.Expr and forwards
everything else untouched. The generated Python is byte-identical across all
three scripts (verified by hash), matching the rule that the same job must not
produce different output per OS. Both templates get an _expr() helper for their
own two call sites each, mirroring _nnedi3().
Everything falls back to std.Expr when core.akarin is absent, and the shim only
installs when it is present -- so macOS x64 keeps the real core with no wrapper
and no behaviour change.
Parity: 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 exception is the DeHalo_alpha/FineDehalo edge-MASK scale,
where one input value lands on an exact .5 tie: std.Expr rounds half-to-even,
akarin rounds down, so one level in a mask. macOS x64 keeps std.Expr and so
differs by that one level -- far smaller than the ARM/x86 difference already
accepted for nnedi3 vs znedi3 (mean 0.045/255, worst pixel 27/255).
Verified end to end: all 63 heavy integration tests pass against a bundle with
akarin installed and patch 7 applied.
Three tests, covering the three ways this change can regress silently. All of them share a failure mode where the output is still a correct picture, which is why none of them can be replaced by an end-to-end "did it run" assertion. test_93 (per-push): neither template may call core.std.Expr directly. Mirrors test_92 for _nnedi3(). It also asserts the helper's fallback calls the real core.std.Expr rather than itself -- not hypothetical, since a blanket search-and-replace made that fallback infinitely recursive while this change was being written. Verified the test actually bites by reintroducing a direct call, making the fallback recurse, and deleting the helper: all three fail. akarin_expr_parity_test (heavy/nightly): collects the expressions havsfunc generates at runtime -- across every QTGMC preset plus daa/santiag/LSFmod/ DeHalo_alpha/FineDehalo/SMDegrain/Deblock_QED/EdgeCleaner/YAHR -- and compares both implementations over inputs covering the full 0-255 range. Collecting at runtime rather than checking in a list is deliberate: the corpus is mostly f-strings with computed thresholds, so a static scan finds 11 of the 46 real expressions, and the test asserts >= 40 so it cannot pass vacuously if the collector breaks. Measured 46 expressions, 0 errors, worst difference 1 level on exactly 1 expression -- the DeHalo_alpha/FineDehalo edge-mask scale, where one input lands on an exact .5 tie that std.Expr rounds half-to-even and akarin rounds down. The bounds are set at that boundary, so both a larger divergence and an additional differing expression fail. vapoursynth_integration_test: requires the akarin namespace, conditionally. macos-x64 is excluded by design -- its only wheel is macosx_14_0 against a 12.0 bundle (issue #39) -- following the arch-conditional nnedi3 precedent directly above it. Skips cleanly there rather than failing.
Licences: akarin is LGPL-3.0 and statically links LLVM 22.1.2 (Apache-2.0 with LLVM exception), so both get NOTICES entries and their licence texts are added -- neither LGPL-3.0 nor the LLVM exception was in licenses/ before. The akarin entry records that the binary is unmodified and dynamically loaded, which is how the LGPL relinking right is satisfied: replace the file in deps/<platform>/vapoursynth/plugins/. CLAUDE.md: a new "std.Expr on ARM" section covering the measurements, the shim design, why macOS x64 is excluded, the one non-identical expression, and the per-platform placement of the plugin's private libraries. The older arm64 note said the Expr gap was "tracked separately" -- it now points here. havsfunc goes from six patches to seven. Also corrects a number from the original plan: akarin adds ~61 MB uncompressed but only ~21 MB to each deps zip. The plan's "the zips roughly double" compared an uncompressed size against compressed zips. deps-version.json moves to 1.8.0-rc2. rc1 has no akarin, so CI and nightly would otherwise exercise the fallback path on every platform and prove nothing about this change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
VapourSynth's
compile_jit()is wrapped in#ifdef VS_TARGET_CPU_X86, so on ARMevery expression is walked once per pixel by
ExprInterpreter::eval().Measured on an M1 under R78,
Exprcosts 550–640 CPU-seconds in a QTGMC Slowgraph against the interpolator's 30 — it isn't one cost among several, it is
the cost. Routing through akarin's LLVM JIT: QTGMC Slow 11.5s → 2.8s, 4.1×
(3 runs each, 720x576, 120 output frames).
What's here
vapoursynth-akarinPyPI wheel inall three deps scripts, resolved through the JSON API so the hashed file URL is
never hardcoded. Private libs go to
lib/on Unix (notplugins/, which isautoloaded and would probe a non-plugin
.soevery core init);lib/isdeliberately not on
DYLD_LIBRARY_PATH, so the bundled libz can't shadow thesystem one for ffmpeg. Linux's zstd carries a per-arch build hash, so it's globbed.
core.std.Exprsites, so patch 7 rebinds itsmodule
coreto a proxy swapping only.std.Expr: one insertion point. Thegenerated Python is byte-identical across all three scripts (hash-verified).
Both templates get an
_expr()helper for their own sites.core.akarinexists, so where itdoesn't,
corestays the real core: no wrapper, no overhead, no behaviour change.Not bit-identical, and the difference is known
A regex over havsfunc finds only 11 expressions — the rest are f-strings with
computed thresholds — so the corpus was collected at runtime: 46 distinct,
across every QTGMC preset plus daa/santiag/LSFmod/DeHalo_alpha/FineDehalo/
SMDegrain/Deblock_QED/EdgeCleaner/YAHR.
45 of 46 are bit-identical. The exception is the
DeHalo_alpha/FineDehaloedge-mask scale, where one input value lands on an exact
.5tie:std.Exprrounds half-to-even (128), akarin rounds down (127). One level, in a mask.
macOS x64 keeps
std.Expr— its only wheel ismacosx_14_0against a 12.0bundle (issue #39) — and so differs by that one level. Unavoidable, and far
smaller than the ARM/x86 difference already accepted for nnedi3 vs znedi3 (mean
0.045/255, worst pixel 27/255).
Tests
test_93(per-push) forbids a directcore.std.Exprin either template, andrequires the helper's fallback to call the real thing rather than itself.
Verified it fails on all three regressions rather than assumed.
akarin_expr_parity_test(nightly) rebuilds the corpus and compares bothimplementations across all 256 input values. Asserts
corpus >= 40so a brokencollector fails instead of passing vacuously; bounds are set at exactly what is
measured today.
akarinnamespace required conditionally, skipping macos-x64, following thearch-conditional
nnedi3precedent.Size and licensing
+20–25 MB per deps zip; macos-x64 unchanged at +0.0 MB, confirming the
exclusion works. The original plan's "the zips roughly double" compared an
uncompressed size against compressed zips.
akarin is LGPL-3.0 and statically links LLVM 22.1.2 (Apache-2.0 with LLVM
exception); both licence texts and NOTICES entries added.
Built and tested against
deps-v1.8.0-rc2.