Repository navigation
Reduce load time and first-use latency - #1124
Conversation
Julia doesn't specialize on a `T::Type` argument that is only passed on, so the precompiled `Metal.rand(Float32, ...)` called `rand!` on an abstractly typed array. Verifying that edge when loading Metal means looking it up against GPUArrays' large `AnyGPUArray` unions, which took about 40 ms of every `using Metal`.
Several host functions involved in compiling and launching a kernel were specialized on its type, which is new for every kernel the user defines, so that every new kernel compiled them again: the closure that `mtlfunction` passed to `GPUCompiler.cached_lookup` (whose type depends on the kernel's), calling a `HostKernel`, and the first-use path of `device()`, which is inlined into code specialized per kernel. Link the kernel with a callable struct instead, launch through an unspecialized `launch_kernel`, and move `device()`'s initialization out of line. This saves about 11 ms of compilation for every new kernel.
Labelling every pipeline with its kernel's Julia signature compiled printing code on the first kernel launch of a session (showing types and integers, regular expressions, string concatenation), which took about 15 ms. The signatures are only of use in Xcode's frame capture and in Instruments, so only compute them when `label_resources` is enabled, like the other labels, and otherwise label pipelines with the name of the generated function.
Whether Metal's workload can use the GPU was decided in two places: the platform, preference and device checks guarding `precompile.jl`, and the command API check inside it. Downstream packages whose workloads launch kernels, like AcceleratedKernels' Metal extension, need the combination, so provide it as `can_precompile_launches()` and use it in Metal's own workload.
`invoke_frozen` hides the result types of the GPUCompiler calls in `compile_to_metallib`, intrinsics metadata was collected in an untyped `Dict`, and the generator binding kernel arguments read the parameters of types it doesn't know to be `DataType`s. The code using those values was dispatched dynamically, and its callees compiled anew in every session. Asserting the types saves about 25 ms on compiling the first kernel of a session.
The first time a session displayed an `MtlArray` compiled about 100 ms of printing code, `Metal.zeros` and `Metal.fill` compiled a `fill!` kernel for 75 ms, and a first kernel with an atomic operation compiled the host code for atomics for over 100 ms. Exercise those in the workload, along with broadcasting a multiplication by a scalar. The REPL displays arrays to a terminal, which can't be created while precompiling, so the workload precompiles `show` for it rather than calling it.
|
Documentation preview: https://metal.juliagpu.org/previews/PR1124/ |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1124 +/- ##
==========================================
- Coverage 88.94% 88.93% -0.01%
==========================================
Files 105 105
Lines 9362 9291 -71
==========================================
- Hits 8327 8263 -64
+ Misses 1035 1028 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Benchmark resultsCompared with c0a323ab3, the merge base. Times are the minimum time of every benchmark. A change is unusual when it exceeds both three times the benchmark's typical change between consecutive runs on the default branch and a minimum per category (performance 3%, latency 5%, applications 3%, other 3%). A benchmark that starts or stops allocating is listed however few the allocations. A single run can still be a fluke, so rerun before drawing conclusions.
All benchmarks
History: https://juliagpu.org/perf/?pkg=metal |
A round of load-time and first-use latency fixes, from profiling
using Metaland the first operations of a fresh session on an M1 (Julia 1.12 and 1.13).Metal.rand(Float32, ...)calledrand!on an abstractly typed array, because Julia doesn't specialize on aT::Typeargument that is only passed on. Verifying that edge against GPUArrays'AnyGPUArrayunions took about 40 ms of everyusing Metal(Metal's image: 389 → 348 ms on 1.13, 504 → 466 ms on 1.12).GPUCompiler.cached_lookup, calling aHostKernel, anddevice()'s first-use path, inlined into code specialized per kernel. A callable struct, an unspecializedlaunch_kerneland an out-of-linedevice()initialization bring a second new kernel from 38 to 27 ms. The launch specialization test now covers these. Launch overhead is unchanged.label_resourcesis enabled now; otherwise pipelines are labelled with the generated function's name.invoke_frozenhid the result types of the GPUCompiler calls incompile_to_metallib, so the code using them was dynamically dispatched and its callees compiled in every session; asserting the types (and typing the intrinsicsDictandbind_arguments' parameters) saves ~25 ms on the first kernel (300 → 270 ms).show81 → 8 ms; REPL display 105 → 15 ms, through a precompile statement since a terminal can't be created while precompiling), runsMetal.zeros/fill(103 → 28 ms), a scalar multiplication (108 → 1 ms) and a kernel with an atomic (185 → 58 ms), for 0.6 MiB more image and ~4 ms more load time.can_precompile_launches()(internal) combines the checks that decide whether Metal's workload can launch kernels, so that downstream workloads can use them too: AcceleratedKernels' Metal extension needs it to precompile its tuned reductions and scans, which Metal's own workload can't (Precompile the Metal operations that depend on the extension's tunings AcceleratedKernels.jl#200).Related, in other packages: GPUCompiler's workload now compiles a representative kernel and avoids dynamic dispatch in back-end IR code (JuliaGPU/GPUCompiler.jl#1070, #1071), and ObjectiveC.jl's generated property methods shrink (JuliaInterop/ObjectiveC.jl#92, #93), which together take another ~100 ms off loading Metal and ~60-100 ms off the first kernel. Most of what's left of the first kernel's host compilation is JuliaLang/julia#58465.