Is there a way to combine special argument types that denote size through macros?

A code example would speak best to what I’m trying to achieve:

Crystal 1.3.0 (2022-01-06)

struct Matrix(T, W, H)
  {% begin %}
    property values : StaticArray(T, {{W * H}})
  {% end %}

  def initialize
    @values = StaticArray(T, {{ W * H }}).new(T.new(0))
  end
end

Is there a way to specify (through macros, or otherwise?) that the StaticArray type is W * H in size somehow?

I could use values : Slice(T) or values : Pointer(T) but in benchmarking the allocation and multiplication of various collection types in this struct, my results showed StaticArray to be faster than pointers:

                   user     system      total        real
pointer        0.243436   0.235193   0.478629 (  0.254651)
slice          0.234756   0.220551   0.455307 (  0.244790)
static array   0.117237   0.151175   0.268412 (  0.124297)
tuple          0.116266   0.147461   0.263727 (  0.122882)
vars           0.118009   0.153231   0.271240 (  0.125376)

I’ve tried using the TypeNode#type_vars macro method, but that seems to return a literal W * H in this usage, which isn’t valid.

However, using this same approach in methods returns what I’d expect:

  struct Matrix(T, W, H)
    def self.size
      {{ @type.type_vars[1] * @type.type_vars[2] }}
    end

    def size
      {{ @type.type_vars[1] * @type.type_vars[2] }}
    end
  end

  Matrix(Int32, 3, 3).size # => 9
  Matrix(Int32, 3, 3).new.size # => 9

Appreciate any help/insight, thank you!

1 Like

Hi!

There’s no way to do that right now. We can use this forum topic to discuss how we could allow that… I’m not sure! Maybe math operators could be allowed in type arguments.

Interesting, I thought numbers in the type_vars list were hardcoded to only be allowed for StaticArray. TIL!

I remember there was once a proposal about adding arithmetic expressions to the type grammar. But I can’t find it in the depths of GitHub issues.

Thanks for the replies, I searched the issues, but couldn’t find a request either so I created a feature request on GitHub.

Please leave cross-references between related discussions so people can find them later:

2 Likes

Maybe that’s not what you want, but you can do

macro def_struct(w, h)
  struct Matrix{{w}}x{{h}}(T)
    property values : StaticArray(T, {{w * h}})

    def initialize
      @values = StaticArray(T, {{ w * h }}).new(T.new(0))
    end
  end
end

{% begin %}
  {% for i in 1..10 %} # alternatively, limit to actually useful sizes
    {% for j in 1..10 %}
      def_struct({{i}}, {{j}})
    {% end %}
  {% end %}
{% end %}

# ...
a = Matrix3x3(Int32).new
puts a

b = Matrix5x1(Int32).new
puts b

Well, there is even better way: StaticArray of StaticArrays. It is more convenient to use (@values[1][2]) and have same layout in memory:

struct Matrix(T, W, H)
  property values : StaticArray(StaticArray(T, H), W)

  def initialize
    @values = StaticArray(StaticArray(T, H), W).new(StaticArray(T, H).new(T.new(0)))
  end

  def to_plain
    {% begin %}
    @values.unsafe_as(StaticArray(T, {{ W * H }}))
  {% end %}
  end
end

a = Matrix(Int32, 3, 3).new
puts a
puts a.to_plain
puts sizeof(Matrix(Int32, 3, 3)) / sizeof(Int32)
2 Likes

I did attempt this approach, however it comes with the caveat that setting a specific value requires you to overwrite the entire sub-static array:

    def []=(col : Int, row : Int, value : T)
      c = @values[col] # "checkout" row for changes
      c[row] = value # set the col value within the row
      @values[col] = c # reassign the changed row to the static array of rows
    end

I also must be doing a large number of allocations by doing this because the benchmarks showed this approach to actually be slower than @values : Slice(T)

1 Like

This gave me the idea of trying

pp! StaticArray(Int8, sizeof(StaticArray(StaticArray(Int8, 5), 4))) # => StaticArray(Int8, 20)

and it works, but when trying to use the matrix it fails :frowning:

struct Matrix(T, W, H)
  property values : StaticArray(T, sizeof(StaticArray(StaticArray(T, W), H)))

  def initialize
    @values = StaticArray(T, sizeof(StaticArray(StaticArray(T, W), H))).new(T.new(0))
  end
end

pp! Matrix(Int8, 4, 5).new.values

pp! Matrix(Int32, 2, 3).new.values
% crystal matrix.ign.cr
BUG: unknown node in TypeLookup: sizeof(StaticArray(StaticArray(T, W), H)) SizeOf (Exception)
  from raise<Exception>:NoReturn
  from raise<String>:NoReturn
  from Crystal::Type::TypeLookup#lookup<Cr

Almost!

2 Likes

what about this? Slice would add runtime bounds check (Pointer won’t but this is asking for bugs) but still no allocations, so it should be fast.

struct Matrix(T, W, H)
  property values : StaticArray(StaticArray(T, W), H)

  def initialize
    @values = StaticArray(StaticArray(T, W), H).new(StaticArray(T, W).new(T.new(0)))
  end

  private def to_slice
    pointerof(@values).unsafe_as(Pointer(T)).to_slice(W*H)
  end

  def []=(col : Int, row : Int, value : T)
    to_slice[row*W + col] = value
  end

  def [](col : Int, row : Int) : T
    @values[row][col] # of course to_slice[row*W + col] would work too.
  end
end

a = Matrix(Int32, 3, 7).new
a[1, 2] = 1
a[2, 1] = 2
puts a, a[1, 2], a[2, 1]

This is my crack at it:

struct FastMatrix(T, W, H, C)
  property values : StaticArray(T, C)
  
  def initialize
    {% if C != W * H %}
      {% raise "Invalid FastMatrix! C must be the product of W and H!" %}
    {% end %}
    @values = StaticArray(T, C).new(T.new(0))
  end
end

module Matrix(T, W, H)
  def self.new
    {% begin %}
      {% c = @type.type_vars[1] * @type.type_vars[2] %}
      FastMatrix(T, W, H, {{c}}).new
    {% end %}
  end
end

mat = Matrix(Float64, 3, 2).new

p! mat.values.size # => 6

Run it with carc.in

Edit:

There might be a more elegant way to do it, but at least you can check at compile-time to make sure that the size is right.

Oh, and you could even just make a little macro that makes a FastMatrix, too:

macro new_mat(t, w, h)
  FastMatrix({{t}}, {{w}}, {{h}}, {{w * h}}).new
end

Run it with carc.in

The problem in your solution is that sometimes explicit type is needed.
E.g. for method arguments restriction:

def apply_matrix(m1 : Matrix(Float64, 3, 3)) # not working
def apply_matrix(m1 : FastMatrix(Float64, 3, 3, 9)) # isn't convenient

This can be solved though by including Matrix in FastMatrix:

module Matrix(T, W, H)
  def self.new
    {% begin %}
      {% c = @type.type_vars[1] * @type.type_vars[2] %}
      FastMatrix(T, W, H, {{c}}).new
    {% end %}
  end
end

struct FastMatrix(T, W, H, C)
  include Matrix(T, W, H)
  property values : StaticArray(T, C)
  
  def initialize
    {% if C != W * H %}
      {% raise "Invalid FastMatrix! C must be the product of W and H!" %}
    {% end %}
    @values = StaticArray(T, C).new(T.new(0))
  end
end

def apply(m : Matrix(Float64, 3, 3))
  p! m
end

mat = Matrix(Float64, 3, 2).new
other_mat = Matrix(Float64, 3, 3).new
# apply(mat)  - correctly rejected
apply(other_mat)

But I’m not sure that it won’t break at some point later (for example, it doesn’t compile if FastMatrix declared before Matrix).

2 Likes