Skip to content

Fix implementations of eigen and eigvals - #757

Open
devmotion wants to merge 7 commits into
masterfrom
dw/eigen
Open

Fix implementations of eigen and eigvals#757
devmotion wants to merge 7 commits into
masterfrom
dw/eigen

Conversation

@devmotion

Copy link
Copy Markdown
Member

The PR adds missing definitions of eigvals and eigen for Hermitian{<:Dual} and of eigen for Hermitian{<:Complex{<:Dual}}.
Additionally, the PR adds more tests of eigen and eigvals, unifies the existing implementations, and removes duplicate calculations in the definitions of eigen.

Fixes #756 without GenericLinearAlgebra.


For the example in #756, I get on ForwardDiff@0.10 without GenericLinearAlgebra:

julia> test_hessian(Float64)
ERROR: MethodError: no method matching eigvals!(::Hermitian{ForwardDiff.Dual{…}, Matrix{…}}; sortby::Nothing)
The function `eigvals!` exists, but no method is defined for this combination of argument types.
...

julia> test_hessian(ComplexF64)
ERROR: MethodError: no method matching eigen!(::Hermitian{Complex{ForwardDiff.Dual{…}}, Matrix{Complex{…}}}; sortby::Nothing)
The function `eigen!` exists, but no method is defined for this combination of argument types.
...

And on ForwardDiff@0.10 with import GenericLinearAlgebra: eigen:

julia> @time test_hessian(Float64)
  0.000071 seconds (38 allocations: 33.750 KiB)
9×9 Matrix{Float64}:
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  4.44089e-16  0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0
 0.0  0.0  0.0  0.0  4.44089e-16  0.0  0.0  2.66454e-15  0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0          0.0

julia> @time test_hessian(ComplexF64)
  0.000556 seconds (1.97 k allocations: 1.387 MiB)
18×18 Matrix{Float64}:
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0  -6.93889e-18  0.0     0.0  0.0  0.0  -1.38778e-16  0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   4.33681e-19  0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   1.38778e-17  0.0     0.0  0.0  0.0   2.77556e-17  0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0   0.0          0.0

On this PR I get without GenericLinearAlgebra:

julia> @time test_hessian(Float64)
  0.000071 seconds (215 allocations: 55.547 KiB)
9×9 Matrix{Float64}:
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  2.77556e-17   0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  0.0           0.0
 0.0  0.0  0.0  0.0  2.77556e-17  0.0  0.0  0.0          -2.77556e-17
 0.0  0.0  0.0  0.0  0.0          0.0  0.0  2.77556e-17   0.0

julia> @time test_hessian(ComplexF64)
  0.000491 seconds (1.00 k allocations: 477.500 KiB)
18×18 Matrix{Float64}:
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   6.93889e-18  0.0     0.0  0.0  0.0  0.0  -1.38778e-17  0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0  -6.93889e-18  0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0    0.0  0.0  0.0  0.0   0.0          0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   2.77556e-17  0.0
 0.0  0.0  0.0  0.0   0.0          0.0     0.0  0.0  0.0  0.0   0.0          0.0

Some benchmarks against master:

master

julia> using ForwardDiff, Chairmarks, LinearAlgebra

## Symmetric{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Symmetric(x * x'))), _)
11.312 μs (110 allocs: 49.297 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Symmetric(x * x')).values), _)
29.083 μs (273 allocs: 115.469 KiB)

## Hermitian{<:Complex}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(complex.(x * x', x' * x)))), _)
71.625 μs (4136 allocs: 542.594 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(complex.(x * x', x' * x))).values), _)
ERROR: MethodError: no method matching eigen!(::Hermitian{Complex{ForwardDiff.Dual{…}}, Matrix{Complex{…}}}; sortby::Nothing)
...

## SymTridiagonal{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(SymTridiagonal(x, x[begin:(end-1)]))), _)
14.500 μs (128 allocs: 37.625 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(SymTridiagonal(x, x[begin:(end-1)])).values), _)
32.625 μs (314 allocs: 101.422 KiB)

## Hermitian{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(x * x'))), _)
ERROR: MethodError: no method matching eigvals!(::Hermitian{ForwardDiff.Dual{ForwardDiff.Tag{…}, Float64, 10}, Matrix{ForwardDiff.Dual{…}}}; sortby::Nothing)
The function `eigvals!` exists, but no method is defined for this combination of argument types.
...

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(x * x')).values), _)
ERROR: MethodError: no method matching eigen!(::Hermitian{ForwardDiff.Dual{ForwardDiff.Tag{…}, Float64, 10}, Matrix{ForwardDiff.Dual{…}}}; sortby::Nothing)
The function `eigen!` exists, but no method is defined for this combination of argument types.
...

This PR

julia> using ForwardDiff, Chairmarks, LinearAlgebra

## Symmetric{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Symmetric(x * x'))), _)
15.834 μs (110 allocs: 49.297 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Symmetric(x * x')).values), _)
19.500 μs (133 allocs: 67.594 KiB)

## Hermitian{<:Complex}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(complex.(x * x', x' * x)))), _)
32.625 μs (136 allocs: 98.062 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(complex.(x * x', x' * x))).values), _)
39.875 μs (159 allocs: 132.047 KiB)

## Symtridiagonal{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(SymTridiagonal(x, x[begin:(end-1)]))), _)
10.938 μs (148 allocs: 31.062 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(SymTridiagonal(x, x[begin:(end-1)])).values), _)
14.125 μs (171 allocs: 49.359 KiB)

## Hermitian{<:Real}
julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigvals(Hermitian(x * x'))), _)
15.917 μs (110 allocs: 49.297 KiB)

julia> @b rand(10) ForwardDiff.gradient(x -> sum(eigen(Hermitian(x * x')).values), _)
19.250 μs (133 allocs: 67.594 KiB)

@araujoms

araujoms commented Jul 25, 2025

Copy link
Copy Markdown

Amazing. I can confirm it fixes not only the MWE I posted, but also the real code I extracted it from.

Note that there is still a regression if the matrix is not wrapped in Hermitian, that is, if we change the function to

    function barrier(point)
        d2 = div(d, 2)
        M = mat(point, T)
        M[1:d2, d2+1:end] .= 0
        M[d2+1:end, 1:d2] .= 0
        return sum(real(eigvals(M)))
    end

It doesn't matter to me, I never need a non-Hermitian matrix, I'm remarking just in case you wanted to know.

Comment thread test/JacobianTest.jl
Comment on lines +266 to +271
# analytic solutions
@test ForwardDiff.jacobian(eigvals_symreal, x) ≈ [0 0; 2 4]
@test ForwardDiff.jacobian(eigvals_hermreal, x) ≈ [0 0; 2 4]
if !(x isa StaticArrays.StaticArray)
@test ForwardDiff.jacobian(eigvals_symtridiag, x) ≈ [(1 - 3/sqrt(5))/2 (1 - 1/sqrt(5))/2 ; (1 + 3/sqrt(5))/2 (1 + 1/sqrt(5))/2]
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't looked very closely, but are there more tests somewhere that check the values?

In what's changed in this PR, I see one more line with Calculus.finite_difference_jacobian(ev, float.(x0)) where I think x0 = [1.0, 2.0].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there are more in the lines below. Much more exhaustive than on master.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry that wasn't clear. I see the many tests of different paths against each other, which is good.

But what I meant is tests of any ForwardDiff path against something completely independent -- a known answer, or finite differences. To catch things like making the same logic error in writing both _eigvals_hermitian and _eigen_hermitian here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the tests below, Jacobians of every test function with ForwardDiff are checked against finite differencing based Jacobians. The latter doesn't involve any ForwardDiff paths.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry to be dense but where are you looking? Besides line 293, as mentioned:

I see one more line with Calculus.finite_difference_jacobian(ev, float.(x0)) where I think x0 = [1.0, 2.0].

@devmotion devmotion Jul 26, 2025

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is line 293. Calculus doesn't support all vector types, hence the ForwardDiff results with x (the possibly static version of x0: lines 259 and 262) is compared with the finite differencing result using the regular array x0.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

So the matrices being tested are these. Does it seem OK to test e.g. no negative eigenvalue for Symmetric{Real}, no zero eigenvalues for Hermitian{Complex}? Haven't thought hard but it just seemed a small sample.

julia> x
2-element Vector{Float64}:
 1.0
 2.0

julia> Symmetric(x*x')  # also wrapped in Hermitian
2×2 Symmetric{Float64, Matrix{Float64}}:
 1.0  2.0
 2.0  4.0

julia> eigvals(ans)
2-element Vector{Float64}:
 0.0
 5.0

julia> Hermitian(complex.(x*x', x'*x))
2×2 Hermitian{ComplexF64, Matrix{ComplexF64}}:
 1.0+0.0im  2.0+5.0im
 2.0-5.0im  4.0+0.0im

julia> eigvals(ans)
2-element Vector{Float64}:
 -3.0901699437494754
  8.090169943749475

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(That said, when I tried various tests locally, I did not manage to break this.)

@devmotion

Copy link
Copy Markdown
Member Author

The PR fixes #780 as well: #780 (comment)

Comment thread test/JacobianTest.jl
devmotion and others added 5 commits October 6, 2025 22:25
# Conflicts:
#	src/dual.jl
The matrices in the `eigen` testset are all of the form `x*x'` and hence
symmetric, so `:U` and `:L` wrap the same matrix and the `uplo` handling
these methods do is never exercised.

Add a case whose raw storage is deliberately not symmetric. On master
these fail: `eigvals(Symmetric(A, :L))` reads the values from the upper
triangle -- `Symmetric(value.(parent(A)))` drops the `uplo` -- while
reading the partials through the wrapper, so the two come from different
matrices and the Jacobian is off by 0.198.

The eigenvector case needs a matrix whose eigenvector direction actually
moves: for `[x1 x2; x2 x1+x2]`, `tan(2θ) = 2x2/(a-d) = -2` is constant,
so the derivative is identically zero and the test would pass either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`(Q' * ∂A) * Q` starts with `Adjoint * Symmetric`, which has no BLAS
specialization: it falls back to the generic `*(::AbstractMatrix,
::AbstractMatrix)`, allocating an extra n^2 temporary per partial and
skipping `symm`/`hemm` entirely (`mul!(C, Q', Symmetric(M))` is 1003 ns
against 169 ns for `mul!(C, Q', M)` at n = 10).

`∂A * Q` is `Symmetric * Matrix`, which does dispatch to `symm`, and the
remaining `Q' * _` is `Adjoint * Matrix`, i.e. `gemm`. Same result, one
fewer temporary, and the halved flop count now actually materializes.

This removes the one regression against master -- `eigvals(Symmetric)`
was 11.67 μs / 127 allocs / 58.5 KiB against master's 10.62 μs / 107 /
49.3 KiB, and is now 10.54 μs / 107 / 49.3 KiB -- and improves the rest
(gradient over a length-10 input):

                              master              this branch
  eigvals Symmetric      10.62 μs 49.3 KiB    10.54 μs  49.3 KiB
  eigen   Symmetric      25.54 μs 115.4 KiB   13.58 μs  68.6 KiB
  eigvals Hermitian{C}   51.58 μs 544.8 KiB   25.75 μs 100.3 KiB
  eigen   SymTridiagonal 26.38 μs 89.4 KiB    14.00 μs  50.3 KiB

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@devmotion

Copy link
Copy Markdown
Member Author

Refreshed this branch: merged current master, added a regression test for the uplo handling, and fixed the one performance regression it had against master.

Merged master

The base was ~10 months old. One trivial conflict: master inserted the LinearAlgebra.givensAlgorithm section (#783) immediately above the eigen section header this PR renames. The full suite passes locally; CI on this push will confirm.

uplo = :L was fixed here but never tested

Every matrix in the eigen testset is of the form x*x' and hence symmetric, so :U and :L wrap the same matrix and the uplo handling in _structured_value/_structured_partials is never exercised. Added a case whose raw storage is deliberately not symmetric.

On master those fail, because Symmetric(value.(parent(A))) drops the uplo and so reads the values from the upper triangle while the partials are read through the wrapper and honour the actual one:

julia> M = [1.0 2.0; 7.0 4.0]; Ad = Dual{TestTag}.(M, [1.0 0.0; 0.0 0.0]);

julia> ForwardDiff.value.(eigvals(Symmetric(Ad, :L)))   # master
2-element Vector{Float64}:
 0.0
 5.0

julia> eigvals(Symmetric(M, :L))                        # what it should be
2-element Vector{Float64}:
 -4.658910531638176
  9.658910531638175

The Jacobian is off by 0.198 there; this branch matches finite differences to 1e-11 for Symmetric{<:Real}, Hermitian{<:Real} and Hermitian{<:Complex}, in both :U and :L, for eigvals, eigen().values and eigen().vectors.

One wrinkle worth recording: the eigenvector part of that test needs a matrix whose eigenvector direction actually depends on x. For [x1 x2; x2 x1+x2], tan(2θ) = 2x₂/(a-d) = -2 is constant, so the eigenvectors don't move, the derivative is identically zero, and the test would pass with or without the fix.

Associating Q' * (∂A * Q) instead of (Q' * ∂A) * Q

Benchmarking against current master showed eigvals(Symmetric) had got slower — 11.67 μs / 127 allocs / 58.5 KiB against master's 10.62 μs / 107 allocs / 49.3 KiB — which was odd given eigen(Symmetric) got much faster.

The cause is that (Q' * ∂A) * Q starts with Adjoint * Symmetric, which has no BLAS specialization. It falls back to the generic *(::AbstractMatrix, ::AbstractMatrix), which allocates an extra n² temporary per partial direction and skips symm/hemm:

Q' * M                     178.7 ns    2 allocs    944 bytes    # BLAS gemm
Q' * Symmetric(M)          273.5 ns    4 allocs    1.844 KiB    # generic fallback
mul!(C, Q', M)             168.8 ns    0 allocs
mul!(C, Q', Symmetric(M))  1003.0 ns   2 allocs                 # 6x slower

Re-associating fixes it without giving up the structure: ∂A * Q is Symmetric * Matrix, which does dispatch to symm, and the remaining Q' * _ is Adjoint * Matrix, i.e. gemm. Same result, one temporary fewer, and the halved flop count now actually materializes — at n = 100 the structured form finally beats a dense ∂A (32.3 μs vs 52.0 μs), which it did not before (49.5 μs).

SymTridiagonal keeps its wrapper either way and also benefits (23.3 μs vs 31.7 μs at n = 100); there the structure makes the first product O(n²), so it is well worth preserving.

Benchmarks against current master

ForwardDiff.gradient(f, rand(10)), minimum of @b:

master this branch
eigvals(Symmetric{<:Real}) 10.62 μs, 107 allocs, 49.27 KiB 10.54 μs, 107 allocs, 49.27 KiB
eigen(Symmetric{<:Real}).values 25.54 μs, 267 allocs, 115.41 KiB 13.58 μs, 130 allocs, 68.56 KiB
eigvals(Hermitian{<:Real}) MethodError 10.83 μs, 107 allocs, 49.27 KiB
eigen(Hermitian{<:Real}).values MethodError 13.58 μs, 130 allocs, 68.56 KiB
eigvals(Hermitian{<:Complex}) 51.58 μs, 4133 allocs, 544.84 KiB 25.75 μs, 133 allocs, 100.31 KiB
eigen(Hermitian{<:Complex}).values MethodError 33.38 μs, 156 allocs, 136.80 KiB
eigvals(SymTridiagonal) 11.23 μs, 163 allocs, 31.64 KiB 10.56 μs, 143 allocs, 31.02 KiB
eigen(SymTridiagonal).values 26.38 μs, 380 allocs, 89.41 KiB 14.00 μs, 166 allocs, 50.31 KiB

No regressions left; the numbers in the PR description above predate current master and this change.

Two things deliberately not changed

  • _to_duals's complex branch computes tuple.(parts...) twice, once for the real and once for the imaginary part. Hoisting it into a local looks like an obvious win but is a pessimization: inside the fused broadcast the tuples are never materialized, so hoisting forces an n²-element array of N-tuples into existence — 448 KiB against 240 KiB at n = 40, N = 8, to buy ~8% time. Left as is.
  • The repeated-eigenvalue case still divides by a vanishing eigenvalue gap and yields NaN/Inf eigenvector derivatives, exactly as on master (and as in ChainRules, which tracks it as an open TODO). That is Differentiate general eigen and eigvals #788's territory, not this PR's.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.23%. Comparing base (569af35) to head (44813de).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #757      +/-   ##
==========================================
+ Coverage   90.68%   91.23%   +0.54%     
==========================================
  Files          11       11              
  Lines        1052     1072      +20     
==========================================
+ Hits          954      978      +24     
+ Misses         98       94       -4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@devmotion
devmotion requested a review from mcabbott August 14, 2026 20:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression in 1.0, asking for Hessian of a complex matrix function results in infinite loop

3 participants