Skip to content

Always show game payoffs (with StaticArrays optimization)#223

Open
zsunberg wants to merge 6 commits intoQuantEcon:mainfrom
zsunberg:replshow-staticarrays
Open

Always show game payoffs (with StaticArrays optimization)#223
zsunberg wants to merge 6 commits intoQuantEcon:mainfrom
zsunberg:replshow-staticarrays

Conversation

@zsunberg
Copy link
Copy Markdown
Contributor

@zsunberg zsunberg commented Apr 22, 2026

This is an improvement on #218

It changes g[a1,a2] to return an SVector avoiding allocations. show still allocates because it has to deal with the string length of the payoff profiles, but the performance is drastically improved over main and #218 (which were about the same when printing the entire game):

  ┌────────────────────────────────────────────────┬──────────┬─────────┬───────────┬────────────┐                                          
  │                      case                      │ min time │ median  │  allocs   │   memory   │                                          
  ├────────────────────────────────────────────────┼──────────┼─────────┼───────────┼────────────┤                                          
  │ NFG 100×100 println on main                    │ 3286 ms  │ 3310 ms │ 4,068,307 │ 795.62 MiB │                                          
  ├────────────────────────────────────────────────┼──────────┼─────────┼───────────┼────────────┤                                          
  │ NFG 100×100 show in #218 (Vector profiles)     │ 3536 ms  │ 3609 ms │ 4,398,404 │ 804.25 MiB │                                          
  ├────────────────────────────────────────────────┼──────────┼─────────┼───────────┼────────────┤                                          
  │ NFG 100×100 show this PR (SVector profiles)    │ 166 ms   │ 177 ms  │ 2,368,488 │ 129.51 MiB │                                          
  ├────────────────────────────────────────────────┼──────────┼─────────┼───────────┼────────────┤                                          
  │ Matrix{Float64} 100×200 (same scalar count)    │ 2.25 ms  │ 2.56 ms │ 80,030    │ 9.71 MiB   │                                          
  └────────────────────────────────────────────────┴──────────┴─────────┴───────────┴────────────┘                                          

Eliminates per-cell heap allocation when showing a NormalFormGame. On a
100x100 two-player game, show goes from ~3.5 s / 804 MiB to ~170 ms /
130 MiB.
@oyamad
Copy link
Copy Markdown
Member

oyamad commented Apr 23, 2026

@zsunberg Great, thanks!

If we go one step further, we may want to implement Base.show(io::IO, g::NormalFormGame) to print something that is parsable (which is the case with Player).

(Suppose your 2-arg Base.show(io::IO, g::NormalFormGame) is implemented as 3-arg Base.show(io::IO, ::MIME"text/plain", g::NormalFormGame).)

  1. I initially thought of doing this:

    function Base.show(io::IO, g::NormalFormGame)
        X = LazyProfileArray(g)
        print(io, "NormalFormGame(")
        show(IOContext(io, :typeinfo => typeof(X)), X)
        print(io, ")")
    end

    This doesn't work properly:

    g = random_game(1:3, (2, 2, 2))
    show(g)
    NormalFormGame([[2, 1, 1] [1, 1, 2]; [2, 2, 2] [2, 3, 3];;; [1, 1, 3] [1, 3, 2]; [1, 1, 1] [3, 1, 3]])
    

    because [[2, 1, 1] [1, 1, 2]; [2, 2, 2] [2, 3, 3];;; [1, 1, 3] [1, 3, 2]; [1, 1, 1] [3, 1, 3]] produces a 6×2×2 Array (as a feature of Julia):

    6×2×2 Array{Int64, 3}:
    [:, :, 1] =
     2  1
     1  1
     1  2
     2  2
     2  3
     2  3
    
    [:, :, 2] =
     1  1
     1  3
     3  2
     1  3
     1  1
     1  3
    
  2. Suppose we merged Array of Tuples constructor #221. Then NormalFormGame([(2, 1, 1) (1, 1, 2); (2, 2, 2) (2, 3, 3);;; (1, 1, 3) (1, 3, 2); (1, 1, 1) (3, 1, 3)]) (with tuples (2, 1, 1), ... in place of vectors [2, 1, 1], ...) works fine:

    2×2×2 NormalFormGame{3, Int64}
    

    If we mimic your LazyProfileArray:

    struct LazyTupleArray{N,T} <: AbstractArray{NTuple{N,T},N}
        g::NormalFormGame{N,T}
    end
    
    Base.size(a::LazyTupleArray) = a.g.nums_actions
    
    function Base.getindex(a::LazyTupleArray{N,T}, index::Vararg{Int,N}) where {N,T}
        return ntuple(i -> a.g.players[i].payoff_array[
            ntuple(k -> index[mod1(k + i - 1, N)], Val(N))...
        ], Val(N))
    end
    
    function Base.show(io::IO, g::NormalFormGame)
        print(io, "NormalFormGame(", LazyTupleArray(g), ")")
    end
    g = random_game(1:3, (2, 2, 2))
    show(g)
    NormalFormGame([(3, 1, 1) (1, 1, 3); (3, 1, 2) (3, 3, 1);;; (2, 2, 3) (2, 1, 1); (2, 2, 2) (2, 2, 1)])
    

    (3e1b4eb)

    One downside is that we would have to maintain two objects LazyProfileArray and LazyTupleArray...

@zsunberg
Copy link
Copy Markdown
Contributor Author

I agree! Especially since the docs say that show(io, g) should be parseable when possible.

One minor note: I don't think we need the LazyTupleArray because we will always be outputting the entire array, so it is probably OK to allocate it (this will be fast because we will not need to allocate a Vector for each entry, and if there is not enough memory to allocate this array, there will probably be other problems showing it). We'll still need LazyProfileArray because we want to avoid collecting all the payoffs into a matrix when we're only displaying a few of them in the REPL. Since show(io, g) will always print all the values, it is not much extra cost to collect them.

If you want to take a shot at this, please do (maybe merge #221 into this?). It will be a couple more days before I have time to work on it again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants