diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a062f89..c296bb0 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,6 +1,6 @@ name: CI - on: + workflow_dispatch: push: branches: - master @@ -14,13 +14,13 @@ concurrency: env: JULIA_PKG_SERVER: "" - # Fix for Windows: Forces GR/Plots to be headless JULIA_NUM_THREADS: 1 JULIA_PKG_PRECOMPILE_AUTO: 0 + # headless GR/Plots GKSwstype: "100" - # Fix for macOS: Forces Matplotlib to use non-interactive backend + # non-interactive Matplotlib for PyPlot MPLBACKEND: "Agg" - # Force Julia to use Conda.jl + # force Conda.jl rather than a system Python PYTHON: "" jobs: @@ -31,25 +31,41 @@ jobs: fail-fast: false matrix: version: - - '1.10' # LTS - - '1' # Latest Stable + - '1.12' os: - ubuntu-latest - - windows-latest - - macos-latest # Apple Silicon (ARM64) + - macOS-latest # Apple Silicon (ARM64) arch: - x64 - - aarch64 exclude: - - os: ubuntu-latest - arch: aarch64 - - os: windows-latest + - os: macOS-latest + arch: x64 + include: + - os: macOS-latest arch: aarch64 - - os: macos-latest - arch: x64 # ARM only + version: '1.12' steps: - uses: actions/checkout@v4 + + - name: Check out ContinuousWavelets.jl + uses: actions/checkout@v4 + with: + repository: JaredW40/ContinuousWavelets.jl + ref: master + path: deps/ContinuousWavelets.jl + - name: Check out FourierFilterFlux.jl + uses: actions/checkout@v4 + with: + repository: JaredW40/FourierFilterFlux.jl + ref: master + path: deps/FourierFilterFlux.jl + - name: Check out MonogenicFilterFlux.jl + uses: actions/checkout@v4 + with: + repository: JaredW40/MonogenicFilterFlux.jl + ref: main + path: deps/MonogenicFilterFlux.jl - uses: julia-actions/setup-julia@v2 with: @@ -58,27 +74,67 @@ jobs: - uses: julia-actions/cache@v2 - # --- Custom Registry Configuration --- - name: Configure Git to use HTTPS - run: | - git config --global url."https://github.com/".insteadOf "git@github.com:" + run: git config --global url."https://github.com/".insteadOf "git@github.com:" - name: Add Registries - shell: julia --project={0} {0} + shell: julia --color=yes {0} run: | using Pkg - Pkg.Registry.add(RegistrySpec(name="General")) + Pkg.Registry.add("General") Pkg.Registry.add(RegistrySpec(url="https://github.com/UCD4IDS/TetrapodsRegistry")) - # ------------------------------------- - - uses: julia-actions/julia-buildpkg@v1 + - name: Generate the CI environment + run: | + julia --project=ci -e ' + using Pkg + Pkg.develop([ + PackageSpec(path = "deps/ContinuousWavelets.jl"), + PackageSpec(path = "deps/FourierFilterFlux.jl"), + PackageSpec(path = "deps/MonogenicFilterFlux.jl"), + PackageSpec(path = "."), + ]) + Pkg.add([ + PackageSpec(name = "Test"), + PackageSpec(name = "Wavelets"), + PackageSpec(name = "Zygote"), + PackageSpec(name = "Flux"), + PackageSpec(name = "FFTW"), + PackageSpec(name = "AbstractFFTs"), + PackageSpec(name = "Functors"), + PackageSpec(name = "Adapt"), + PackageSpec(name = "MLDataDevices"), + PackageSpec(name = "LinearAlgebra"), + PackageSpec(name = "Logging"), + PackageSpec(name = "Random"), + ])' - - uses: julia-actions/julia-runtest@v1 + - name: Add the CUDA stack (Linux only) + if: runner.os == 'Linux' + run: | + julia --project=ci -e ' + using Pkg + Pkg.add([ + PackageSpec(name = "CUDA"), + PackageSpec(name = "cuDNN"), + PackageSpec(name = "cuFFT"), + PackageSpec(name = "CUDNN_jll", version = "9.20"), + ])' + - name: Add Metal (macOS only) + if: runner.os == 'macOS' + run: julia --project=ci -e 'using Pkg; Pkg.add("Metal")' - - uses: julia-actions/julia-processcoverage@v1 + - name: Precompile + run: julia --project=ci -e 'using Pkg; Pkg.precompile()' - - uses: codecov/codecov-action@v5 + - name: Run tests + env: + GROUP: ${{ runner.os == 'Linux' && 'CUDA' || runner.os == 'macOS' && 'Metal' || 'CPU' }} + run: julia --project=ci --code-coverage=user -e 'include("test/runtests.jl")' + + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v4 with: files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false + fail_ci_if_error: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3b14e53..e76f0ff 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/Project.toml b/Project.toml index 5008075..eadf0f9 100644 --- a/Project.toml +++ b/Project.toml @@ -1,80 +1,60 @@ name = "ScatteringTransform" uuid = "eadaac29-8b6b-5395-80fd-9ce36bb46293" -version = "0.9.0" -author = ["David Weber ", "Naoki Saito ", "Jared White ", "Brian Knight "] +version = "0.8.3" +authors = ["David Weber ", "Naoki Saito ", "Jared White ", "Brian Knight "] [deps] AbstractFFTs = "621f4979-c628-5d54-868e-fcf4e3e8185c" Adapt = "79e6a3ab-5dfb-504d-930d-738a2a938a0e" -BlackBoxOptim = "a134a8b2-14d6-55f6-9291-3336d3ab0209" -CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" ChainRules = "082447d4-558c-5d27-93f4-14fc19e9eca2" ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" ContinuousWavelets = "96eb917e-2868-4417-9cb6-27e7ff17528f" -DSP = "717857b8-e6f2-59f4-9121-6e50c889abd2" +CUDNN_jll = "62b44479-cb7b-5706-934f-f13b2eb2e645" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" -Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" -DistributedArrays = "aaf54ef3-cdf8-58ed-94cc-d582ad619b94" -Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" FFTW = "7a1cc6ca-52ef-59f5-83cd-3a7055c09341" -FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" -FiniteDifferences = "26cc04aa-876d-5657-8c51-4c34ba976000" Flux = "587475ba-b771-5e3f-ad9e-33799f191a9c" FourierFilterFlux = "3d7dfd45-6c90-4c9b-b697-194a05757159" Functors = "d9f16b24-f501-4c13-a1f2-28368ffc5196" -GLMNet = "8d5ece8b-de18-5317-b113-243142960cc6" -HDF5 = "f67ccb44-e63f-5c2f-98bd-6dc0ccc4ba2f" -Interpolations = "a98d9a8b-a2ab-59e6-89dd-64a1c18fca59" -JLD = "4138dd39-2aa7-5051-a626-17a0bb65d9c8" -JLD2 = "033835bb-8acc-5ee8-8aae-3f567f8a3819" -LaTeXStrings = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" -Latexify = "23fbe1c1-3f47-55db-b15f-69d7ec21a316" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" -MLJ = "add582a8-e3ab-11e8-2d5e-e98b27df1bc7" -MLJBase = "a7f614a8-145f-11e9-1d2a-a57a1082229d" MonogenicFilterFlux = "27e6407a-9159-4acb-a68a-dfae66d408cf" NNlib = "872c559c-99b0-510c-b3b7-b6c96a88d5cd" -NearestNeighbors = "b8a86587-4115-5ab1-83bc-aa920d37bbce" -Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" -Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" -Reexport = "189a3867-3050-52da-a836-e630ba90ab69" -SharedArrays = "1a1011a3-84de-559e-8e89-a11a2f7dc383" -SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" Wavelets = "29a6e085-ba6d-5f35-a997-948ac2efa89a" -WaveletsExt = "8f464e1e-25db-479f-b0a5-b7680379e03f" Zygote = "e88e6eb3-aa80-5325-afca-941959d7151f" + +[weakdeps] +CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" +CUDA_Runtime_Discovery = "1af6417a-86b4-443c-805f-a4643ffb695f" cuDNN = "02a925ec-e4fe-4b08-9a7e-0d78e3d38ccd" cuFFT = "533571aa-0936-420e-b4be-9c66f5f626ca" +Metal = "dde4c033-4e86-420c-a63e-0dd931031962" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [extensions] +CUDAExt = ["CUDA", "cuDNN", "cuFFT"] +MetalExt = "Metal" ScatteringPlotsExt = "Plots" [compat] AbstractFFTs = "1" -Adapt = "3, 4" -CUDA = "4 - 6" +Adapt = "4" ContinuousWavelets = "1" -Distributions = "0.25" +CUDA = "6.1" +cuDNN = "6.1" +CUDNN_jll = "~9.20" +cuFFT = "6.1" FFTW = "1" -FileIO = "1" -FiniteDifferences = "0.12.34" Flux = "0.13, 0.14, 0.15, 0.16" Functors = "0.5.2" -JLD2 = "0.4, 0.5, 0.6" +julia = "1.12" Plots = "1.41.6" -Reexport = "1" Wavelets = "0.9, 0.10" -WaveletsExt = "0.2.3" Zygote = "0.6, 0.7" -cuFFT = "6.2.0" -julia = "1.10" [extras] BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf" -CUDA = "052768ef-5323-5732-b1bb-66c8b64840ba" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "CUDA", "BenchmarkTools"] +test = ["Test", "BenchmarkTools"] diff --git a/docs/src/plots.md b/docs/src/plots.md index 6cf9c10..6ba5afe 100644 --- a/docs/src/plots.md +++ b/docs/src/plots.md @@ -3,6 +3,7 @@ ## 1D Plotting Functions ```@docs +ScatteringPlotsExt.plotOriginalSignal1D ScatteringPlotsExt.plotZerothLayer1D ScatteringPlotsExt.plotFirstLayer1DSingleWavelet ScatteringPlotsExt.gifFirstLayer1D diff --git a/ext/CUDAExt.jl b/ext/CUDAExt.jl new file mode 100644 index 0000000..c930723 --- /dev/null +++ b/ext/CUDAExt.jl @@ -0,0 +1,20 @@ +module CUDAExt + +#= This extension is intentionally minimal. `stFlux`'s device transfer is + handled generically by `Functors.@functor stFlux (mainChain,)` plus + Flux's own `gpu`/`cpu` - no CUDA-specific code is needed here for that. + `ConvFFT`'s actual CUDA-specific behavior already lives entirely in + FourierFilterFlux's own CUDAExt, and gets inherited automatically once + FourierFilterFlux is loaded. + + This file's real job is triggering activation: `[extensions] CUDAExt = + ["CUDA", "cuDNN", "cuFFT"]` in Project.toml means all three must be + loaded before this extension (and therefore this package's CUDA support) + activates at all, so this `using` line is what actually makes that + happen, not a formality. It also guarantees FourierFilterFlux's and + ContinuousWavelets' own CUDA extensions are active by the time anything + in ScatteringTransform runs on the GPU, since their trigger sets + (CUDA+cuFFT, and CUDA+cuDNN+cuFFT, respectively) are subsets of this one. =# +using ScatteringTransform, CUDA, cuDNN, cuFFT + +end \ No newline at end of file diff --git a/ext/MetalExt.jl b/ext/MetalExt.jl new file mode 100644 index 0000000..42a1bb6 --- /dev/null +++ b/ext/MetalExt.jl @@ -0,0 +1,5 @@ +module MetalExt + +using ScatteringTransform, Metal + +end \ No newline at end of file diff --git a/src/ScatteringTransform.jl b/src/ScatteringTransform.jl index 14fe4c6..7579cfd 100644 --- a/src/ScatteringTransform.jl +++ b/src/ScatteringTransform.jl @@ -2,17 +2,15 @@ module ScatteringTransform using Core: @__doc__ using ChainRules -using CUDA using Wavelets, ContinuousWavelets using Zygote, Flux, LinearAlgebra, AbstractFFTs -using Flux using FourierFilterFlux using MonogenicFilterFlux using Adapt +using Functors using RecipesBase using Base: tail using ChainRulesCore -# using Plots # who cares about weight really? using Statistics using Dates @@ -20,6 +18,7 @@ import Adapt: adapt import ChainRules: rrule import Zygote: has_chain_rrule, rrule import Wavelets: eltypes +import Flux: gpu, cpu include("shared.jl") @@ -31,7 +30,7 @@ export Scattered, ScatteredFull, ScatteredOut, nonZeroPaths, cat, addNextPath include("pool.jl") export RationPool, nPoolDims, outputsize, poolSize include("transform.jl") -export cu +export gpu, cpu @doc """ scatteringTransform(inputSize, m=2, backend::UnionAll=stFlux; kwargs...) @@ -63,4 +62,4 @@ for f in [:plotOriginalSignal1D, :plotZerothLayer1D, :plotFirstLayer1DSingleWave @eval export $f end -end # end Module +end # end Module \ No newline at end of file diff --git a/src/scattered.jl b/src/scattered.jl index 3a981b0..b574abb 100644 --- a/src/scattered.jl +++ b/src/scattered.jl @@ -32,6 +32,12 @@ struct ScatteredFull{T,N} <: Scattered{T,N} end end +Functors.@functor ScatteredFull (data, output) + +function ScatteredFull(m::Int, k::Int, data, output) + ScatteredFull{eltype(output),length(output)}(m, k, data, output) +end + function ScatteredFull(data, output, k=1) @assert eltype(data) == eltype(output) ScatteredOut{eltype(output),length(output)}(length(output) - 1, k, @@ -50,11 +56,17 @@ struct ScatteredOut{T,N} <: Scattered{T,N} output end +Functors.@functor ScatteredOut (output,) + function ScatteredOut(output, k=1) ScatteredOut{eltype(output),length(output)}(length(output) - 1, k, output) end +function ScatteredOut(m::Int, k::Int, output) + ScatteredOut{eltype(output),length(output)}(m, k, output) +end + arrayType(::ScatteredOut{T,N}) where {T,N} = T noutputs(::ScatteredOut{T,N}) where {T,N} = N @@ -62,10 +74,14 @@ noutputs(::ScatteredOut{T,N}) where {T,N} = N @doc """ ScatteredOut(m, k, fixDim::Array{<:Real, 1}, n::Array{<:Real, 2}, q::Array{<:Real, 1}, T::DataType) -The resulting output of a scattering transform. `m` is the number of layers, while `k` is the actual signal dimension. `fixDim` is the size of any auxillary dimensions for the input (for example if the input is a list of `nEx` examples of length 100 each, then `fixDim=(nEx,)`, while `n` is a list of the subsampled signal sizes and `q` is a list of the paths, as generated by the scatteringTransform). Finally, the element type is `T`. Initializes all entries to zero. +The resulting output of a scattering transform. `m` is the number of layers, while `k` is the actual signal dimension. +`fixDim` is the size of any auxillary dimensions for the input (for example if the input is a list of `nEx` examples of +length 100 each, then `fixDim=(nEx,)`, while `n` is a list of the subsampled signal sizes and `q` is a list of the paths, +as generated by the scatteringTransform). Finally, the element type is `T`. Initializes all entries to zero. ScatteredOut(output, k = 1) -A less involved constructor given just a list or tuple of the output from each layer. `k` gives the signal dimension, as above, with the default that `k=1`. +A less involved constructor given just a list or tuple of the output from each layer. `k` gives the signal dimension, as +above, with the default that `k=1`. """ function ScatteredOut(m, k, fixDim, n, q, T; arrType=Array) @assert m + 1 == size(n, 1) @@ -303,8 +319,11 @@ end """ nonZeroPaths(sc; wholePath=true, allTogetherInOne=false) -Given a `Scattered`, return the `pathLocs` where the `Scattered` is nonzero. `wholePath=true` if it returns the whole path, and not just the specific location in the signal. For example, if only `sc(pathLocs(1,(30,2)))` is nonzero, if `wholePath` is `true`, then `pathLocs(1,(2,))` will be returned while if `wholePath` is `false`, `pathLocs(1,(30,2))` will be returned instead. -if `allTogetherInOne` is `false`, then each location is returned separately, otherwise they are joined into a single `pathLocs`. +Given a `Scattered`, return the `pathLocs` where the `Scattered` is nonzero. `wholePath=true` if it returns the whole +path, and not just the specific location in the signal. For example, if only `sc(pathLocs(1,(30,2)))` is nonzero, if +`wholePath` is `true`, then `pathLocs(1,(2,))` will be returned while if `wholePath` is `false`, `pathLocs(1,(30,2))` +will be returned instead. if `allTogetherInOne` is `false`, then each location is returned separately, otherwise they +are joined into a single `pathLocs`. """ function nonZeroPaths(sc; wholePath=true, allTogetherInOne=false) if wholePath @@ -413,8 +432,8 @@ end function makeSingle(prev, x::BitArray) almostNull = falses(size(x)) # only set this layer to true if all the previous are nothing - if typeof(prev) <: Tuple{Vararg{Nothing}} # || !any.(any.(prev[prev .!=nothing])) + if typeof(prev) <: Tuple{Vararg{Nothing}} almostNull[findfirst(x)] = true end return (prev..., almostNull) -end +end \ No newline at end of file diff --git a/src/scatteringplots.jl b/src/scatteringplots.jl index 0e9c278..f563c29 100644 --- a/src/scatteringplots.jl +++ b/src/scatteringplots.jl @@ -1,6 +1,6 @@ """ - plotZerothLayer1D(sf; saveTo=nothing, index=1) -Function that plots the zeroth layer of the scattering transform at a specified example index. + plotOriginalSignal1D(f; title="Original Signal", saveTo=nothing, index=1) +Function that plots the original signal at a specified example index. """ function plotOriginalSignal1D(f; title="Original Signal", saveTo=nothing, index=1) plt = plot(f[:,1,index], title=title, legend=false, xlim=(0, length(f[:, 1, index])+1), color=:blue, margin=5Plots.mm, size=(720,480)) diff --git a/src/shared.jl b/src/shared.jl index 71098b0..1092802 100644 --- a/src/shared.jl +++ b/src/shared.jl @@ -1,6 +1,7 @@ @doc """ scatteringTransform{Dimension,Depth} - The abstract type and constructor for scattering transforms. The specific types are `stFlux` in this package, and `stParallel` in [ParallelScattering.jl](https://github.com/dsweber2/ParallelScattering.jl/). + The abstract type and constructor for scattering transforms. The specific types are `stFlux` in +this package, and `stParallel` in [ParallelScattering.jl](https://github.com/dsweber2/ParallelScattering.jl/). """ abstract type scatteringTransform{Dimension,Depth} end @@ -12,6 +13,8 @@ struct stFlux{Dimension,Depth,ChainType,D,E,F} <: scatteringTransform{Dimension, settings::F end +Functors.@functor stFlux (mainChain,) + import Base.ndims @doc """ ndims(s::scatteringTransform{D}) diff --git a/src/transform.jl b/src/transform.jl index c42b0bc..5a21b1e 100644 --- a/src/transform.jl +++ b/src/transform.jl @@ -83,8 +83,7 @@ ScatteredOut{Array{Float32},3} 1 dim. OutputSizes: function stFlux(inputSize::NTuple{N}, m=2; outputPool = 2, poolBy = 3//2, σ = abs, normalize = true, flatten = false, trainable = false, kwargs...) where {N} #= N is the length of the NTuple, from which the spatial dimension of input - signals can be determined. E.g., N=3 <=> 1D signals; N=4 <=> 2D images, etc. - =# + signals can be determined. E.g., N=3 <=> 1D signals; N=4 <=> 2D images, etc. =# Nd = N - 2 # Now, setting the outputPool for multi layers if length(outputPool) == 1 # replicate for each dimension and layer @@ -104,7 +103,7 @@ function stFlux(inputSize::NTuple{N}, m=2; outputPool = 2, poolBy = 3//2, listOfSizes = [(inputSize..., ntuple(i -> 1, max(i - 1, 0))...) for i = 0:m] interstitial = Array{Any,1}(undef, 3 * (m + 1) - 2) #= `interstitial` is an array of 3 functions per layer: - 1) wavelet filtering; 2) nonlinearity; 3) pooling + 1) wavelet filtering; 2) nonlinearity; 3) pooling =# for i = 1:m # from Layer 1 to Layer m # first transform @@ -167,18 +166,80 @@ function dispatchLayer(listOfSizes, Nd::Val{1}; varargs...) #= For 1D input signals, we use the conventional wavelet filters available in our `ContinuousWavelets.jl` package. The following function is defined in yet another package of ours: `FourierFilterFlux.jl` that is related to the popluar -`Flux.jl`. -=# +`Flux.jl`. =# waveletLayer(listOfSizes; varargs...) end +#= The 1D and 2D filter banks take different keyword vocabularies, and stFlux + forwards whatever it was given to whichever one `Nd` selects. Two of those + differences can cause issues: + + - 1D accepts anything `ContinuousWavelets.wavelet` accepts (`cw`, `β`, + `averagingLength`, `frameBound`, ...) because `waveletLayer` funnels + its `varargs...` there. There is no wavelet in the 2D path at all, the + monogenic bank is built from the Riesz kernel and Gaussian high/low + passes, so those arguments have no 2D meaning. + + - Both have a padding/convolution boundary, but under different names. + `waveletLayer` calls it `convBoundary` and reserves `boundary` for the + wavelet boundary it hands to `wavelet`. `MonogenicLayer` calls its + convolution boundary `boundary`. So `boundary = PerBoundary()`, correct + in 1D, lands in the 2D convolution slot and fails in `effectiveSize` + three frames down with no hint of the real problem. + + Rather than let those surface as raw MethodErrors from inside + MonogenicFilterFlux, this is layer will partly remedy the boundary naming + issues. `convBoundary` is accepted here as the padding boundary in both + dimensions, so a single spelling works either way. =# + +# Keywords MonogenicLayer accepts, plus `convBoundary` which is translated below. +const MONOGENIC_KWARGS = (:scale, :boundary, :convBoundary, :init, :dType, :σ, + :trainable, :plan, :averagingLayer, :Monotype) + +# Keywords that only mean something to the 1D wavelet path. +const WAVELET_ONLY_KWARGS = (:cw, :β, :Q, :averagingLength, :averagingType, + :frameBound, :normalization, :extraOctaves, :p, :s, :decreasing, :fsample, + :J, :bias) + function dispatchLayer(listOfSizes, Nd::Val{2}; varargs...) #= For 2D input signals (i.e., images), we use the Monogenic Wavelet Scattering -Networks (MWSNs) of Chak and Saito. The following function is defined in our -package `MonogenicFilterFlux.jl`. -=# - #shearingLayer(listOfSizes; varargs...) - MonogenicLayer(listOfSizes; varargs...) +Networks (MWSNs) of Chak and Saito, from our `MonogenicFilterFlux.jl`. =# + args = Dict{Symbol,Any}(varargs) + + #= A ContinuousWavelets boundary means the caller is thinking of the 1D + wavelet stage, which does not exist here. Say that, rather than letting + it reach `effectiveSize` as a convolution boundary. =# + if haskey(args, :boundary) && args[:boundary] isa ContinuousWavelets.WaveletBoundary + error(""" + `boundary = $(args[:boundary])` is a ContinuousWavelets wavelet boundary, and the 2D + (monogenic) path has no wavelet stage for it to apply to. If you meant the padding + used by the convolution, pass `convBoundary` instead, with one of + FourierFilterFlux's boundary types: `Periodic()`, `Sym()` or `Pad(n...)`.""") + end + + unknown = setdiff(keys(args), MONOGENIC_KWARGS) + if !isempty(unknown) + waveletish = intersect(unknown, WAVELET_ONLY_KWARGS) + msg = "unsupported keyword argument$(length(unknown) == 1 ? "" : "s") for a 2D " * + "scattering transform: $(join(sort(collect(unknown)), ", "))." + if !isempty(waveletish) + msg *= "\n$(join(sort(collect(waveletish)), ", ")) configure the 1D wavelet " * + "filter bank; the 2D path builds a monogenic bank instead and has no " * + "wavelet to configure." + end + msg *= "\nThe 2D path accepts: scale, Monotype, convBoundary, σ, dType, " * + "trainable, plan, averagingLayer, init." + error(msg) + end + + # one spelling for the padding boundary across both dimensions + if haskey(args, :convBoundary) + haskey(args, :boundary) && error( + "pass either `convBoundary` or `boundary` for the convolution padding, not both") + args[:boundary] = pop!(args, :convBoundary) + end + + MonogenicLayer(listOfSizes; args...) end Base.size(a::Tuple{AbstractFFTs.Plan,AbstractFFTs.Plan}) = size(a[1]) @@ -202,25 +263,26 @@ function (St::stFlux{Dimension,Depth})(x::T) where {Dimension,Depth,T<:AbstractA end if get(St.settings, :flatten, false) # it may not be defined, in which case we don't do it batchSize = size(res[1])[end] - return cat((reshape(x, (:, batchSize)) for x in res)..., dims=1) + return cat(map(o -> reshape(o, (:, batchSize)), res)..., dims=1) else return ScatteredOut(res, ndims(mc[1])) end end -# adapt changes both the eltype and the container type, while I just want a different container type -function maybeAdapt(contType, x) - if contType <: CuArray && !(typeof(x) <: CuArray) - # should be a CuArray but isn't - return cu(x) - elseif contType <: Array && !(typeof(x) <: Array) - # should be an Array but isn't - return adapt(Array, x) - else - return x - end +_container(x::AbstractArray) = Base.typename(typeof(x)).wrapper +_container(::Type{T}) where {T<:AbstractArray} = Base.typename(T).wrapper + +_onDevice(x::AbstractArray) = _container(x) !== Array + +function maybeAdapt(reference, x) + want = _container(reference) + return want === _container(x) ? x : adapt(want, x) end +_referenceArray(w::AbstractArray{<:Number}) = w +_referenceArray(w) = _referenceArray(first(w)) +_referenceArray(layer::Union{ConvFFT,MonoConvFFT}) = _referenceArray(layer.weight) + """ extractAddPadding(x, adr, chunkSize, N) From `x`, extract the examples `adr` in the last dimension, and make sure that it has a size of `chunkSize`, padding if there are too few examples (this is to make sure the batch size matches). @@ -240,25 +302,25 @@ trim(x, actualSize) = x[axes(x)[1:end-1]..., 1:actualSize] function breakAndAdapt(St::stFlux{N,D}, x) where {N,D} mc = St.mainChain.layers cpu_chunk = size(mc[1].fftPlan)[end] - chunkSize = if x isa CuArray + chunkSize = if _onDevice(x) min(size(x)[end], cpu_chunk * 32) # up to 32x larger chunks on GPU else cpu_chunk end nSteps = ceil(Int, (size(x)[end]) / chunkSize) # the first entry is taken care of already - containerType = typeof(St.mainChain[1].weight[1]) + reference = _referenceArray(St.mainChain[1]) xAxes = axes(x) firstAddr = 1+0:min(size(x)[end], chunkSize) firstEx, actualSize = extractAddPadding(x, firstAddr, chunkSize, N) # x[xAxes[1:end-1]..., 1:chunkSize] # do the first beforehand to get the sizes - out = applyScattering(mc, maybeAdapt(containerType, firstEx), ndims(St), St, 0) + out = applyScattering(mc, maybeAdapt(reference, firstEx), ndims(St), St, 0) # create storage # outputs = map(o -> maybeAdapt(typeof(o), zeros(eltype(o), size(o)[1:end-1]..., size(x)[end]...)), out) outputs = map(o -> maybeAdapt(typeof(o), fill!(similar(o, size(o)[1:end-1]..., size(x)[end]), zero(eltype(o)))), out) # util to write out to outputs at location batchInds function writeOut!(out, batchInds, actualSize) for jj in 1:length(out) - mA = maybeAdapt(typeof(x), out[jj]) + mA = maybeAdapt(x, out[jj]) oAx = axes(outputs[jj]) @views outputs[jj][oAx[1:end-1]..., batchInds] = trim(mA, actualSize) end @@ -269,7 +331,7 @@ function breakAndAdapt(St::stFlux{N,D}, x) where {N,D} # clear output to force garbage collection, otherwise the gpu may be full addr = 1+(ii-1)*chunkSize:min(size(x)[end], ii * chunkSize) tmpX, actualSize = extractAddPadding(x, addr, chunkSize, N) - out = applyScattering(mc, maybeAdapt(containerType, tmpX), ndims(St), St, 0) + out = applyScattering(mc, maybeAdapt(reference, tmpX), ndims(St), St, 0) writeOut!(out, addr, actualSize) end return outputs diff --git a/src/utilities.jl b/src/utilities.jl index a673b5d..c3e0912 100644 --- a/src/utilities.jl +++ b/src/utilities.jl @@ -6,11 +6,6 @@ function mapEvery3(to, ii, x) x end end -import FourierFilterFlux.cu -function cu(stf::stFlux{Dimension,Depth,ChainType,D,E,F}) where {Dimension,Depth,ChainType,D,E,F} - newChain = Chain((map(iix -> (iix[1] % 3 == 1 ? cu(iix[2]) : iix[2]), enumerate(stf.mainChain)))...) - return stFlux{Dimension,Depth,typeof(newChain),D,E,F}(newChain, stf.normalize, stf.outputSizes, stf.outputPool, stf.settings) -end import Adapt.adapt function adapt(to, stf::stFlux{Dimension,Depth,ChainType,D,E,F}) where {Dimension,Depth,ChainType,D,E,F} newChain = Chain((map(iix -> mapEvery3(to, iix...), enumerate(stf.mainChain)))...) @@ -75,7 +70,7 @@ julia> f2' function getMeanFreq(sc::stFlux{1}, δt=1000) waves = getWavelets(sc)[1:end-1] shrinkage = [size(waves[i+1], 1) / size(waves[i], 1) for i = 1:length(waves)-1] - δts = δt * [1, shrinkage...] + δts = δt .* cumprod([1.0; shrinkage]) freqs = map(getMeanFreq, waves, δts) return (freqs..., [zero(freqs[1][1])]) end @@ -118,28 +113,6 @@ function roll(toRoll, stOutput::S) where {S<:Scattered} return roll(toRoll, oS, Nd) end -#= -function roll(toRoll, oS::Tuple, Nd) - toRoll = collect(toRoll) - - nExamples = size(toRoll)[2:end] - rolled = ([fill!(similar(toRoll, eltype(toRoll), - sz[1:Nd+nPathDims(ii)]..., - nExamples...),0) for (ii, sz) in - enumerate(oS)]...,) - - locSoFar = 0 - for (ii, x) in enumerate(rolled) - szThisLayer = oS[ii][1:Nd+nPathDims(ii)] - totalThisLayer = prod(szThisLayer) - range = (locSoFar+1):(locSoFar+totalThisLayer) - addresses = (szThisLayer..., nExamples...) - rolled[ii][:] = reshape(toRoll[range, :], addresses) - locSoFar += totalThisLayer - end - return ScatteredOut(rolled, Nd) -end -=# function roll(toRoll, oS::Tuple, Nd) toRoll = collect(toRoll) @@ -324,4 +297,4 @@ function reshapeInputs(dataMat; is2DData=false) error("Input data must be a vector, matrix, or 3D array.") end return reshapedData, dims -end +end \ No newline at end of file diff --git a/test/2DTests.jl b/test/2DTests.jl new file mode 100644 index 0000000..f468c16 --- /dev/null +++ b/test/2DTests.jl @@ -0,0 +1,180 @@ +#= 2D scattering transform tests, parameterised by device =# + +""" + run2DTests(; name, toDevice, arrayType, sync) + +Run the 2D suite on one device. When `arrayType` is not `Array`, every result +is additionally checked against the CPU transform, so the GPU path is verified +for agreement rather than merely for not throwing. +""" +function run2DTests(; name="CPU", toDevice=identity, arrayType=Array, + sync=() -> nothing) + + onGPU = arrayType !== Array + inputSize = (32, 32, 1, 2) + Nd, m = 2, 2 + + st = toDevice(stFlux(inputSize, m)) + + @testset "$name - construction and accessors" begin + @test st isa stFlux + @test ndims(st) == Nd + @test depth(st) == m + # three stages per layer, minus the two the final averaging layer omits + @test length(st.mainChain.layers) == 3 * (m + 1) - 2 + @test length(st.outputSizes) == m + 1 + @test sprint(show, st) isa String + end + + @testset "$name - layers are monogenic" begin + layers = st.mainChain.layers + for i in 1:(m+1) + @test layers[3*i-2] isa MonoConvFFT + @test ndims(layers[3*i-2]) == Nd + @test layers[3*i-2].weight isa arrayType + end + @test layers[3*m+1].averagingLayer == true + @test layers[1].averagingLayer == false + end + + @testset "$name - forward pass" begin + x = randn(Float32, inputSize) + res = st(toDevice(x)) + sync() + + @test res isa ScatteredOut + @test length(res.output) == m + 1 + + #= The sizes stFlux recorded at construction must match what the chain + actually produces. The most informative assertion here: if 2D is + subtly mis-wired, recorded and real sizes diverge at this line. =# + for i in 1:(m+1) + @test size(res.output[i]) == Tuple(st.outputSizes[i]) + end + + @test all(o -> eltype(o) == Float32, res.output) + @test all(o -> all(isfinite, Array(o)), res.output) + # zeroth layer is the averaged input: no path dimension + @test ndims(res.output[1]) == Nd + 2 + + if onGPU + @test all(o -> o isa arrayType, res.output) + + cpuRes = cpu(st)(x) + for i in 1:(m+1) + @test size(cpuRes.output[i]) == size(res.output[i]) + @test Array(res.output[i]) ≈ cpuRes.output[i] atol = 1.0f-3 + end + end + end + + @testset "$name - flatten" begin + stf = toDevice(stFlux(inputSize, m, flatten=true)) + x = toDevice(randn(Float32, inputSize)) + out = stf(x) + sync() + @test out isa AbstractMatrix + @test size(out, 2) == inputSize[end] + onGPU && @test out isa arrayType + # flattening must not lose or invent coefficients + res = st(x) + @test size(out, 1) == sum(prod(size(o)[1:end-1]) for o in res.output) + end + + @testset "$name - normalize = false" begin + stn = toDevice(stFlux(inputSize, m, normalize=false)) + @test stn(toDevice(randn(Float32, inputSize))) isa ScatteredOut + end + + @testset "$name - batch size mismatch" begin + x = randn(Float32, 32, 32, 1, 5) # plan was built for 2 examples + res = st(toDevice(x)) + sync() + @test res isa ScatteredOut + @test all(o -> size(o)[end] == 5, res.output) + onGPU && @test all(o -> o isa arrayType, res.output) + + # an example must transform the same way regardless of how it is batched + single = st(toDevice(x[:, :, :, 1:2])) + @test Array(res.output[1])[:, :, :, 1:2] ≈ Array(single.output[1]) atol = 1.0f-3 + end + + @testset "$name - gradients" begin + #= flatten = true so the loss sums over a plain array rather than a + ScatteredOut, keeping this about the transform rather than about + Zygote's handling of the output struct. =# + stf = toDevice(stFlux(inputSize, m, flatten=true)) + x = toDevice(randn(Float32, inputSize)) + ∇ = Zygote.gradient(t -> sum(abs2, stf(t)), x)[1] + sync() + @test size(∇) == size(x) + @test eltype(∇) == Float32 + @test all(isfinite, Array(∇)) + @test !all(iszero, Array(∇)) + onGPU && @test ∇ isa arrayType + end + + @testset "$name - depth 1" begin + # the averaging layer immediately follows the first filtering layer + st1 = toDevice(stFlux(inputSize, 1)) + @test depth(st1) == 1 + res = st1(toDevice(randn(Float32, inputSize))) + @test length(res.output) == 2 + end + + @testset "$name - keyword vocabulary" begin + #= The 1D and 2D banks take different keywords, and stFlux forwards + whatever it gets. These used to surface as MethodErrors from inside + MonogenicFilterFlux with no indication of the real problem. =# + @test_throws "no wavelet to configure" stFlux(inputSize, 1, cw=Morlet(π)) + @test_throws "no wavelet to configure" stFlux(inputSize, 1, β=4) + + # a ContinuousWavelets boundary means the caller wants the 1D wavelet + # stage, which does not exist here + @test_throws "convBoundary" stFlux(inputSize, 1, boundary=PerBoundary()) + + # `convBoundary` is the one spelling for padding in both dimensions + stc = toDevice(stFlux(inputSize, 1, convBoundary=FourierFilterFlux.Periodic())) + @test stc.mainChain.layers[1].bc isa FourierFilterFlux.Periodic + @test stc(toDevice(randn(Float32, inputSize))) isa ScatteredOut + + # monogenic-specific settings still reach the layer + sts = stFlux(inputSize, 1, scale=3) + @test sts.mainChain.layers[1].scale == 3 + end + + @testset "$name - non-square input" begin + # a square input hides anywhere the two spatial dimensions get crossed, + # since transposing is then a no-op + sz = (32, 24, 1, 2) + str = toDevice(stFlux(sz, 1)) + res = str(toDevice(randn(Float32, sz))) + @test size(res.output[1]) == Tuple(str.outputSizes[1]) + end + + @testset "$name -- flatten/roll round trip" begin + sst = toDevice(stFlux(inputSize, m, poolBy = 3 // 2)) + x = toDevice(randn(Float32, inputSize)) + res = sst(x) + smooshed = ScatteringTransform.flatten(res) + sync() + + onGPU && @test smooshed isa arrayType + @test size(smooshed, 2) == inputSize[end] + + reconst = roll(smooshed, sst) + sync() + for (a, b) in zip(reconst.output, res.output) + @test size(a) == size(b) + @test Array(a) ≈ Array(b) atol = 1.0f-3 + end + + if onGPU + # and the flattened vector itself must match the CPU transform + cpuRes = cpu(sst)(Array(x)) + @test Array(smooshed) ≈ ScatteringTransform.flatten(cpuRes) atol = 1.0f-3 + end + end + + return nothing +end \ No newline at end of file diff --git a/test/GPUTests.jl b/test/CUDATests.jl similarity index 51% rename from test/GPUTests.jl rename to test/CUDATests.jl index e31d43f..62283f4 100644 --- a/test/GPUTests.jl +++ b/test/CUDATests.jl @@ -1,16 +1,10 @@ -using ScatteringTransform -using ContinuousWavelets -using AbstractFFTs, FFTW -using Test, LinearAlgebra, Statistics -using Flux, FourierFilterFlux, CUDA -using Zygote using BenchmarkTools -const gpu_available = CUDA.functional() +const cuda_available = @isdefined(CUDA) && CUDA.functional() -@testset "GPU Tests" begin - if !gpu_available - @warn "No functional GPU found — skipping GPU tests" +@testset "GPU Tests (CUDA)" begin + if !cuda_available + @warn "No functional CUDA found — skipping CUDA tests" else @info "CUDA functional — running GPU comparison and timing tests" @@ -20,8 +14,8 @@ const gpu_available = CUDA.functional() resCPU = sst(init) - sstGPU = cu(sst) - initGPU = cu(init) + sstGPU = gpu(sst) + initGPU = gpu(init) resGPU = sstGPU(initGPU) @test typeof(resGPU.output[1]) <: CuArray @@ -31,35 +25,14 @@ const gpu_available = CUDA.functional() end end - #= - @testset "CPU/GPU consistency, 2D" begin - n_init_channels = 2 - batch_size = 2 - init = randn(Float32, 32, 32, n_init_channels, batch_size) - sst = stFlux(size(init), 2, poolBy=3 // 2, outputPool=(2,)) - - resCPU = sst(init) - - sstGPU = cu(sst) - initGPU = cu(init) - resGPU = sstGPU(initGPU) - - @test typeof(resGPU.output[1]) <: CuArray - - for (cpuLayer, gpuLayer) in zip(resCPU.output, resGPU.output) - @test cpuLayer ≈ Array(gpuLayer) atol = 1e-3 - end - end - =# - @testset "roll/flatten CPU vs GPU" begin initCPU = randn(Float32, 64, 1, 2) sst = stFlux(size(initCPU), 2, poolBy=3 // 2) resCPU = sst(initCPU) smooshedCPU = ScatteringTransform.flatten(resCPU) - sstGPU = cu(sst) - initGPU = cu(initCPU) + sstGPU = gpu(sst) + initGPU = gpu(initCPU) resGPU = sstGPU(initGPU) smooshedGPU = ScatteringTransform.flatten(resGPU) @@ -77,7 +50,7 @@ const gpu_available = CUDA.functional() @testset "normalize CPU vs GPU" begin x = randn(Float32, 10, 4, 3, 5, 7) - xGPU = cu(x) + xGPU = gpu(x) xpCPU = ScatteringTransform.normalize(x, 2) xpGPU = ScatteringTransform.normalize(xGPU, 2) @@ -95,19 +68,21 @@ const gpu_available = CUDA.functional() @testset "Gradients CPU vs GPU" begin init = randn(Float32, 64, 1, 1) - initGPU = cu(init) + initGPU = gpu(init) sst = stFlux(size(init), 2, poolBy=3 // 2) - sstGPU = cu(sst) + sstGPU = gpu(sst) - CUDA.allowscalar(true) - ∇CPU_Zeroth = Zygote.gradient(x -> sst(x)[0][19,1,1], init)[1] - ∇GPU_Zeroth = Zygote.gradient(x -> sstGPU(x)[0][19,1,1], initGPU)[1] + local ∇CPU_Zeroth, ∇GPU_Zeroth, ∇CPU_First, ∇GPU_First, ∇CPU_Second, ∇GPU_Second + CUDA.@allowscalar begin + ∇CPU_Zeroth = Zygote.gradient(x -> sst(x)[0][19, 1, 1], init)[1] + ∇GPU_Zeroth = Zygote.gradient(x -> sstGPU(x)[0][19, 1, 1], initGPU)[1] - ∇CPU_First = Zygote.gradient(x -> sst(x)[1][11,5,1], init)[1] - ∇GPU_First = Zygote.gradient(x -> sstGPU(x)[1][11,5,1], initGPU)[1] + ∇CPU_First = Zygote.gradient(x -> sst(x)[1][11, 5, 1], init)[1] + ∇GPU_First = Zygote.gradient(x -> sstGPU(x)[1][11, 5, 1], initGPU)[1] - ∇CPU_Second = Zygote.gradient(x -> sst(x)[2][3,5,5,1], init)[1] - ∇GPU_Second = Zygote.gradient(x -> sstGPU(x)[2][3,5,5,1], initGPU)[1] + ∇CPU_Second = Zygote.gradient(x -> sst(x)[2][3, 5, 5, 1], init)[1] + ∇GPU_Second = Zygote.gradient(x -> sstGPU(x)[2][3, 5, 5, 1], initGPU)[1] + end @test typeof(∇GPU_Zeroth) <: CuArray @test Array(∇GPU_Zeroth) ≈ ∇CPU_Zeroth atol = 1e-3 @@ -119,6 +94,60 @@ const gpu_available = CUDA.functional() @test Array(∇GPU_Second) ≈ ∇CPU_Second atol = 1e-3 end + @testset "RationPool CPU vs GPU" begin + subsampRates = [3 // 2, 2, 5 // 2, 6 // 5] + windowSizes = [2, 3, 4] + @testset "i=$i, s=$s, k=$k" for i in (25, 40), s in subsampRates, k in windowSizes + x = randn(Float32, i, 2, 3) + xGPU = gpu(x) + r = RationPool((s,), k) + + SCPU = r(x) + SGPU = r(xGPU) + @test typeof(SGPU) <: CuArray + @test Array(SGPU) ≈ SCPU atol = 1e-3 + + ∇CPU = Flux.gradient(x -> sum(r(x)), x)[1] + ∇GPU = Flux.gradient(x -> sum(r(x)), xGPU)[1] + @test typeof(∇GPU) <: CuArray + @test Array(∇GPU) ≈ ∇CPU atol = 1e-3 + end + end + + @testset "Model & Result conversion round-trips" begin + init = randn(Float32, 64, 1, 2) + sst = stFlux(size(init), 2, poolBy=3 // 2) + resCPU = sst(init) + + @testset "stFlux cpu(gpu(...)) round-trip" begin + sstRoundTrip = cpu(gpu(sst)) + resRoundTrip = sstRoundTrip(init) + @test typeof(resRoundTrip.output[1]) <: Array + for (origLayer, rtLayer) in zip(resCPU.output, resRoundTrip.output) + @test origLayer ≈ rtLayer atol = 1e-3 + end + end + + @testset "ScatteredOut whole-object gpu()/cpu()" begin + sstGPU = gpu(sst) + initGPU = gpu(init) + resGPU_viaModel = sstGPU(initGPU) + + resGPU_viaResult = gpu(resCPU) + + @test typeof(resGPU_viaResult.output[1]) <: CuArray + for (l1, l2) in zip(resGPU_viaModel.output, resGPU_viaResult.output) + @test Array(l1) ≈ Array(l2) atol = 1e-3 + end + + resRoundTrip = cpu(resGPU_viaResult) + @test typeof(resRoundTrip.output[1]) <: Array + for (origLayer, rtLayer) in zip(resCPU.output, resRoundTrip.output) + @test origLayer ≈ rtLayer atol = 1e-3 + end + end + end + @testset "CPU/GPU timing" begin sizes = [256, 2048, 16384, 131072] cpu_max_size = 16384 @@ -129,8 +158,8 @@ const gpu_available = CUDA.functional() init = randn(Float32, sz, 1, 1) sst = stFlux(size(init), 2, poolBy=3 // 2) - sstGPU = cu(sst) - initGPU = cu(init) + sstGPU = gpu(sst) + initGPU = gpu(init) if sz > cpu_max_size CUDA.@sync sstGPU(initGPU) # warmup @@ -144,7 +173,7 @@ const gpu_available = CUDA.functional() speedup = tCPU / tGPU @info "size=$sz" tCPU tGPU speedup if sz >= 512 - @test tGPU < tCPU + @test tGPU < tCPU end sstGPU = nothing diff --git a/test/MetalTests.jl b/test/MetalTests.jl new file mode 100644 index 0000000..c1acef3 --- /dev/null +++ b/test/MetalTests.jl @@ -0,0 +1,184 @@ +using BenchmarkTools + +const metal_available = @isdefined(Metal) && Metal.functional() + +@testset "GPU Tests (Metal)" begin + if !metal_available + @warn "No functional Metal found — skipping Metal tests" + else + @info "Metal functional — running GPU comparison and timing tests" + + @testset "CPU/GPU consistency, 1D" begin + init = randn(Float32, 64, 1, 2) + sst = stFlux(size(init), 2, poolBy=3 // 2) + + resCPU = sst(init) + + sstGPU = gpu(sst) + initGPU = gpu(init) + resGPU = sstGPU(initGPU) + + @test typeof(resGPU.output[1]) <: MtlArray + + for (cpuLayer, gpuLayer) in zip(resCPU.output, resGPU.output) + @test cpuLayer ≈ Array(gpuLayer) atol = 1e-3 + end + end + + @testset "roll/flatten CPU vs GPU" begin + initCPU = randn(Float32, 64, 1, 2) + sst = stFlux(size(initCPU), 2, poolBy=3 // 2) + resCPU = sst(initCPU) + smooshedCPU = ScatteringTransform.flatten(resCPU) + + sstGPU = gpu(sst) + initGPU = gpu(initCPU) + resGPU = sstGPU(initGPU) + smooshedGPU = ScatteringTransform.flatten(resGPU) + + @test typeof(smooshedGPU) <: MtlArray + @test Array(smooshedGPU) ≈ smooshedCPU atol = 1e-3 + + reconstCPU = roll(smooshedCPU, sst) + reconstGPU = roll(smooshedGPU, sstGPU) + + @test all(reconstCPU .≈ resCPU) + for (cpuLayer, gpuLayer) in zip(reconstGPU.output, resGPU.output) + @test Array(cpuLayer) ≈ Array(gpuLayer) atol = 1e-3 + end + end + + @testset "normalize CPU vs GPU" begin + x = randn(Float32, 10, 4, 3, 5, 7) + xGPU = gpu(x) + + xpCPU = ScatteringTransform.normalize(x, 2) + xpGPU = ScatteringTransform.normalize(xGPU, 2) + + @test typeof(xpGPU) <: MtlArray + @test Array(xpGPU) ≈ xpCPU atol = 1e-3 + + for w in eachslice(xpCPU, dims=ndims(x)) + @test norm(w, 2) ≈ 3 * 5 + end + for w in eachslice(Array(xpGPU), dims=ndims(x)) + @test norm(w, 2) ≈ 3 * 5 + end + end + + @testset "Gradients CPU vs GPU" begin + init = randn(Float32, 64, 1, 1) + initGPU = gpu(init) + sst = stFlux(size(init), 2, poolBy=3 // 2) + sstGPU = gpu(sst) + + local ∇CPU_Zeroth, ∇GPU_Zeroth, ∇CPU_First, ∇GPU_First, ∇CPU_Second, ∇GPU_Second + Metal.@allowscalar begin + ∇CPU_Zeroth = Zygote.gradient(x -> sst(x)[0][19, 1, 1], init)[1] + ∇GPU_Zeroth = Zygote.gradient(x -> sstGPU(x)[0][19, 1, 1], initGPU)[1] + + ∇CPU_First = Zygote.gradient(x -> sst(x)[1][11, 5, 1], init)[1] + ∇GPU_First = Zygote.gradient(x -> sstGPU(x)[1][11, 5, 1], initGPU)[1] + + ∇CPU_Second = Zygote.gradient(x -> sst(x)[2][3, 5, 5, 1], init)[1] + ∇GPU_Second = Zygote.gradient(x -> sstGPU(x)[2][3, 5, 5, 1], initGPU)[1] + end + + @test typeof(∇GPU_Zeroth) <: MtlArray + @test Array(∇GPU_Zeroth) ≈ ∇CPU_Zeroth atol = 1e-3 + + @test typeof(∇GPU_First) <: MtlArray + @test Array(∇GPU_First) ≈ ∇CPU_First atol = 1e-3 + + @test typeof(∇GPU_Second) <: MtlArray + @test Array(∇GPU_Second) ≈ ∇CPU_Second atol = 1e-3 + end + + @testset "RationPool CPU vs GPU" begin + subsampRates = [3 // 2, 2, 5 // 2, 6 // 5] + windowSizes = [2, 3, 4] + @testset "i=$i, s=$s, k=$k" for i in (25, 40), s in subsampRates, k in windowSizes + x = randn(Float32, i, 2, 3) + xGPU = gpu(x) + r = RationPool((s,), k) + + SCPU = r(x) + SGPU = r(xGPU) + @test typeof(SGPU) <: MtlArray + @test Array(SGPU) ≈ SCPU atol = 1e-3 + + ∇CPU = Flux.gradient(x -> sum(r(x)), x)[1] + ∇GPU = Flux.gradient(x -> sum(r(x)), xGPU)[1] + @test typeof(∇GPU) <: MtlArray + @test Array(∇GPU) ≈ ∇CPU atol = 1e-3 + end + end + + @testset "Model & Result conversion round-trips" begin + init = randn(Float32, 64, 1, 2) + sst = stFlux(size(init), 2, poolBy=3 // 2) + resCPU = sst(init) + + @testset "stFlux cpu(gpu(...)) round-trip" begin + sstRoundTrip = cpu(gpu(sst)) + resRoundTrip = sstRoundTrip(init) + @test typeof(resRoundTrip.output[1]) <: Array + for (origLayer, rtLayer) in zip(resCPU.output, resRoundTrip.output) + @test origLayer ≈ rtLayer atol = 1e-3 + end + end + + @testset "ScatteredOut whole-object gpu()/cpu()" begin + sstGPU = gpu(sst) + initGPU = gpu(init) + resGPU_viaModel = sstGPU(initGPU) + + resGPU_viaResult = gpu(resCPU) + + @test typeof(resGPU_viaResult.output[1]) <: MtlArray + for (l1, l2) in zip(resGPU_viaModel.output, resGPU_viaResult.output) + @test Array(l1) ≈ Array(l2) atol = 1e-3 + end + + resRoundTrip = cpu(resGPU_viaResult) + @test typeof(resRoundTrip.output[1]) <: Array + for (origLayer, rtLayer) in zip(resCPU.output, resRoundTrip.output) + @test origLayer ≈ rtLayer atol = 1e-3 + end + end + end + + @testset "CPU/GPU timing" begin + sizes = [256, 2048, 16384, 131072] + cpu_max_size = 16384 + + for sz in sizes + GC.gc() + + init = randn(Float32, sz, 1, 1) + sst = stFlux(size(init), 2, poolBy=3 // 2) + sstGPU = gpu(sst) + initGPU = gpu(init) + + if sz > cpu_max_size + Metal.@sync sstGPU(initGPU) # warmup + GC.gc() + tGPU = @elapsed (Metal.@sync sstGPU(initGPU)) + else + tGPU = @belapsed (Metal.@sync $sstGPU($initGPU)) + end + + tCPU = @belapsed $sst($init) + speedup = tCPU / tGPU + @info "size=$sz" tCPU tGPU speedup + if sz >= 512 + @test tGPU < tCPU + end + + sstGPU = nothing + initGPU = nothing + GC.gc() + end + end + end +end \ No newline at end of file diff --git a/test/fluxtests.jl b/test/fluxtests.jl index e986d77..7ff6bd8 100644 --- a/test/fluxtests.jl +++ b/test/fluxtests.jl @@ -99,11 +99,14 @@ @test res1[1:32*3, 1] ≈ reshape(res[0][:, :, 1], (32 * 3,)) end + #= + As of this moment these tests are broken. This is merely because GPU support has not been added to + MonogenicFilterFlux.jl yet. When support is added these tests will be re-enabled. nFilters = [1, 12, 12, 12] @testset "2D basics" begin - n_init_channels=2 + n_init_channels = 2 batch_size = 2 - init = 10 .+ randn(64, 64, n_init_channels, batch_size); + init = 10 .+ randn(64, 64, n_init_channels, batch_size) sst = stFlux(size(init), 2, poolBy=3 // 2, outputPool=(2,)) res = sst(init) @test length(res.output) == 2 + 1 # same @@ -121,26 +124,8 @@ @test res1 isa Array{Float32,2} @test size(res1) == (totalSize, 2) @test res1[1:32^2*n_init_channels, 1] ≈ reshape(res[0][:, :, :, 1], (32^2 * n_init_channels,)) - - sst = stFlux(size(init), 2, poolBy=3 // 2, outputPool=(2,)) - res = sst(init) - # @test - @test length(res.output) == 2 + 1 # same - @test size(res.output[1]) == (32, 32, n_init_channels, batch_size) - @test minimum(abs.(res.output[1])) > 0 - @test size(res.output[2]) == (22, 22, n_init_channels * nFilters[2], 2) - @test minimum(abs.(res.output[2])) > 0 - @test size(res.output[3]) == (14, 14, nFilters[3], n_init_channels * nFilters[2], 2) - @test minimum(abs.(res.output[3])) > 0 - totalSize = 32^2 * n_init_channels + 22^2 * n_init_channels * nFilters[2] + 14^2 * nFilters[3] * n_init_channels * nFilters[2] - smooshed = ScatteringTransform.flatten(res) - @test size(smooshed) == (totalSize, 2) - sst1 = stFlux(size(init), 2, poolBy=3 // 2, outputPool=(2,), flatten=true) - res1 = sst1(init) - @test res1 isa Array{Float32,2} - @test size(res1) == (totalSize, 2) - @test res1[1:32^2*n_init_channels, 1] ≈ reshape(res[0][:, :, :, 1], (32^2 * n_init_channels,)) end + =# nFilters = [1, 10, 9] @testset "1D integer pooling" begin diff --git a/test/runtests.jl b/test/runtests.jl index e2ab2fc..453abb8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,12 +1,38 @@ # tests for the various forms of stParallel for the ScatteringTransform -# using Revise using ScatteringTransform using ContinuousWavelets using AbstractFFTs, FFTW using Test, LinearAlgebra, Statistics -using Flux, FourierFilterFlux, CUDA +using Flux, FourierFilterFlux, MonogenicFilterFlux using Zygote -include("pathTests.jl") -include("fluxtests.jl") -include("GPUTests.jl") +#= GROUP/try-catch/functional() three-layer pattern: + GROUP=All (default) attempt every backend this environment has + GROUP=CUDA only attempt CUDA -- MetalExt/Metal.jl is never touched + GROUP=Metal only attempt Metal -- CUDAExt/CUDA.jl is never touched + GROUP=CPU skip both GPU backends entirely =# +const GROUP = get(ENV, "GROUP", "All") + +@testset "ScatteringTransform.jl" begin + include("pathTests.jl") + include("fluxtests.jl") + include("2DTests.jl") + + if GROUP in ("All", "CUDA") + try + using CUDA, cuDNN, cuFFT + include("CUDATests.jl") + catch e + @info "CUDA/cuDNN/cuFFT not available in this environment -- skipping CUDATests.jl" exception=e + end + end + + if GROUP in ("All", "Metal") + try + using Metal + include("MetalTests.jl") + catch e + @info "Metal not available in this environment -- skipping MetalTests.jl" exception=e + end + end +end \ No newline at end of file