Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SegmentTree/LazySegmentTree/FenwickTreeの演算の受け渡しについて #24

Closed
key-moon opened this issue Sep 8, 2020 · 14 comments
Closed

Comments

@key-moon
Copy link
Contributor

key-moon commented Sep 8, 2020

C++における実装においては、関数ポインタを定数template引数に渡す(SegmentTree/LazySegmentTree), テンプレートに渡される型が整数型であると仮定して、そうでない場合はコンパイルエラーとする(FenwickTree) と言った手法を取っています。

template <class T> struct fenwick_tree {
    using U = internal::to_unsigned_t<T>;
...
    void add(int p, T x) {
        assert(0 <= p && p < _n);
        p++;
        while (p <= _n) {
            data[p - 1] += U(x); //←ここ
        }
    }
template <class S, S (*op)(S, S), S (*e)()> struct segtree

しかし、C#で同様のことを行おうとしても

class Class<T> where T implements +

class Class<T, Merge, E> where Merge is Func<T, T, T>

といったことはできない状況となっています。

そのためC++による元ライブラリの仕様をまるごと移植するのは諦め、何らかの代替策を取る必要があります。
考えうる案としては、

  1. IMonoid インターフェイスを作成し、それを実装したクラスを型引数に渡す
  2. コンストラクタにFunc型を渡す
  3. class Monoidを作成し、それをコンストラクタに渡す MonoidのコンストラクタにはFuncや単位元を渡す

といった手法があります。(他の手法も提案していただけると嬉しいです。)

それぞれの手法のメリットは、

  1. 型として他のセグ木と区別がつく。最もC++でのデザインが意図しているものと近い。
  2. 即時関数(ラムダ式)で気軽に書ける。
  3. 2をMonoidクラスでラップしただけ。意味論としても綺麗になるし、Monoidのインスタンスを使い回せて嬉しい

デメリットとしては、

  1. クラスを一々作らないといけないため、即時関数(ラムダ式)で気軽に書けなくなる
  2. ループ内で何度も初期化するとなると、即時関数を一々初期化したりすると割と速度面で不安。引数も多くキャッシュするのも少し面倒くさい
  3. 2と比べ、いちいちインスタンスを作成するのは少し冗長

となります。

see also: #20 , #1 , #2 , #3

@terry-u16
Copy link
Contributor

モノイドのマージ処理は軽い処理がループ内で何度も呼ばれるという都合上、2.だとやはりデリゲート呼び出し時のオーバーヘッドが不安かな……と思います。1.ならJIT時のインライン化も期待できるので、こちらの方が個人的には嬉しいです。

モノイドを毎回作成するのは面倒ですが、そこはよく使うモノイドを各自で予め用意しておくなりスニペット化するなりするのが良いのかな……と。

またC#8.0以降ならインターフェースに静的メソッド(演算子のオーバーロード含む)を持たせることができるようになったため、a.Merge(b)a + bのように書くことも一応可能です。そこまでやるかはまた議論が必要ですが……。

またこれはライブラリ本体には直接関係がないため完全に余談ではありますが、モノイドをclassにすると毎回ヒープのメモリ確保が生じてしまうため、実際に使用する際はstruct(可能ならreadonly struct)にすると高速化が期待できるかもしれません。

@key-moon
Copy link
Contributor Author

key-moon commented Sep 8, 2020

インターフェースに静的メソッド(演算子のオーバーロード含む)を持たせることができるようになったため
これ知りませんでした。助かります!

オーバーヘッドを考えた場合も1の方が良いかもしれないですね。貴重な意見ありがとうございます🙇‍♂️

@kzrnm
Copy link
Owner

kzrnm commented Sep 8, 2020

https://github.com/naminodarie/AtCoderProject/blob/c05d1ee17d1947a2b437c6928f64cf79c6d2be62/AtCoderLib/%E7%AF%84%E5%9B%B2%E6%BC%94%E7%AE%97/SegmentTree.cs

私のライブラリでは、SegTreeの実装自体は抽象クラスとしてモノイドは抽象メソッドで表現しています。
overrideを打てばVisual Studioが補完してくれますし、オブジェクト指向における「継承より委譲」を無視して継承で実現するのも十分ありかなと思います。

@kzrnm
Copy link
Owner

kzrnm commented Sep 11, 2020

#20 に書いたINumOperator<T>方式でFenwickTreeを動かしてみました。

[MethodImpl(MethodImplOptions.AggressiveInlining)]をつけていると直接操作と同等の速度が出ました。

Benchmarks

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.18363.1082 (1909/November2018Update/19H2)
Intel Core i7-4790 CPU 3.60GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.1.401
[Host] : .NET Core 3.1.7 (CoreCLR 4.700.20.36602, CoreFX 4.700.20.37001), X64 RyuJIT
DefaultJob : .NET Core 3.1.7 (CoreCLR 4.700.20.36602, CoreFX 4.700.20.37001), X64 RyuJIT

Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
AddDirect 9.991 s 0.0546 s 0.0484 s - - - 762.94 MB
AddOperator 10.736 s 0.1936 s 0.1717 s - - - 762.94 MB
AddOperatorInline 10.359 s 0.1659 s 0.1552 s - - - 762.94 MB
SumDirect 1.719 s 0.0164 s 0.0154 s - - - 762.94 MB
SumOperator 1.833 s 0.0161 s 0.0142 s - - - 762.94 MB
SumOperatorInline 1.752 s 0.0078 s 0.0065 s - - - 762.94 MB
Code
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Running;

class Program
{
    static void Main()
    {
        BenchmarkRunner.Run<AclBench>(DefaultConfig.Instance.AddDiagnoser(MemoryDiagnoser.Default));
    }
}

public class AclBench
{
    const int N = 100_000_000;

    [Benchmark]
    [BenchmarkCategory("Add")]
    public long AddDirect()
    {
        var ft = new LongFenwickTree(N);
        for (int i = 0; i < N; i++)
            ft.Add(0, i);
        return ft.Sum(0, N);
    }

    [Benchmark]
    [BenchmarkCategory("Add")]
    public long AddOperator()
    {
        var ft = OperatorFenwickTree.Long(N);
        for (int i = 0; i < N; i++)
            ft.Add(0, i);
        return ft.Sum(0, N);
    }

    [Benchmark]
    [BenchmarkCategory("Add")]
    public long AddOperatorInline()
    {
        var ft = OperatorFenwickTree.LongInline(N);
        for (int i = 0; i < N; i++)
            ft.Add(0, i);
        return ft.Sum(0, N);
    }


    [Benchmark]
    [BenchmarkCategory("Sum")]
    public long SumDirect()
    {
        long sum = 0;
        var ft = new LongFenwickTree(N);
        for (int i = 0; i < N; i++)
            sum += ft.Sum(0, i);
        return sum;
    }

    [Benchmark]
    [BenchmarkCategory("Sum")]
    public long SumOperator()
    {
        long sum = 0;
        var ft = OperatorFenwickTree.Long(N);
        for (int i = 0; i < N; i++)
            sum += ft.Sum(0, i);
        return sum;
    }

    [Benchmark]
    [BenchmarkCategory("Sum")]
    public long SumOperatorInline()
    {
        long sum = 0;
        var ft = OperatorFenwickTree.LongInline(N);
        for (int i = 0; i < N; i++)
            sum += ft.Sum(0, i);
        return sum;
    }
}

public class LongFenwickTree
{
    private readonly long[] data;
    public LongFenwickTree(int n)
    {
        this.data = new long[n + 1];
    }
    public void Add(int p, long x)
    {
        Debug.Assert(unchecked((uint)p < data.Length));
        for (p++; p < data.Length; p += p & -p) // p < data.Lengthで比較すると配列アクセスにJIT最適化がかかる
        {
            data[p] += x;
        }
    }
    public long Sum(int l, int r)
    {
        Debug.Assert(0 <= l && l <= r && r < data.Length);
        return Sum(r) - Sum(l);
    }
    private long Sum(int r)
    {
        long s = 0;
        for (; r > 0; r -= r & -r)
        {
            s += data[r];
        }
        return s;
    }
}

public static class OperatorFenwickTree
{
    public static OperatorFenwickTree<long, LongOperator> Long(int n) => new OperatorFenwickTree<long, LongOperator>(n);
    public static OperatorInlineFenwickTree<long, LongOperator> LongInline(int n) => new OperatorInlineFenwickTree<long, LongOperator>(n);
}
public class OperatorFenwickTree<TValue, TOp>
    where TValue : struct
    where TOp : struct, INumOperator<TValue>
{
    readonly TOp op;
    private readonly TValue[] data;
    public OperatorFenwickTree(int n)
    {
        this.data = new TValue[n + 1];
    }
    public void Add(int p, TValue x)
    {
        Debug.Assert(unchecked((uint)p < data.Length));
        for (p++; p < data.Length; p += p & -p) // p < data.Lengthで比較すると配列アクセスにJIT最適化がかかる
        {
            data[p] = op.Add(data[p], x);
        }
    }

    public TValue Sum(int l, int r)
    {
        Debug.Assert(0 <= l && l <= r && r < data.Length);
        return op.Sub(Sum(r), Sum(l));
    }
    private TValue Sum(int r)
    {
        var s = op.Zero;
        for (; r > 0; r -= r & -r)
        {
            s = op.Add(s, data[r]);
        }
        return s;
    }
}
public class OperatorInlineFenwickTree<TValue, TOp>
    where TValue : struct
    where TOp : struct, INumOperator<TValue>
{
    static readonly TOp op;
    private readonly TValue[] data;
    public OperatorInlineFenwickTree(int n)
    {
        this.data = new TValue[n + 1];
    }
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Add(int p, TValue x)
    {
        Debug.Assert(unchecked((uint)p < data.Length));
        for (p++; p < data.Length; p += p & -p) // p < data.Lengthで比較すると配列アクセスにJIT最適化がかかる
        {
            data[p] = op.Add(data[p], x);
        }
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public TValue Sum(int l, int r)
    {
        Debug.Assert(0 <= l && l <= r && r < data.Length);
        return op.Sub(Sum(r), Sum(l));
    }
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private TValue Sum(int r)
    {
        var s = op.Zero;
        for (; r > 0; r -= r & -r)
        {
            s = op.Add(s, data[r]);
        }
        return s;
    }
}
public interface INumOperator<T> : IComparer<T> where T : struct
{
    public T Zero { get; }
    public bool IsZero(T v);
    public T MaxValue { get; }
    T Add(T v1, T v2);
    T Sub(T v1, T v2);
}
public readonly struct IntOperator : INumOperator<int>
{
    public int Zero => 0;
    public bool IsZero(int v) => v == 0;
    public int MaxValue => int.MaxValue;
    public int Add(int v1, int v2) => v1 + v2;
    public int Sub(int v1, int v2) => v1 - v2;
    public int Compare(int x, int y) => x.CompareTo(y);
}
public readonly struct LongOperator : INumOperator<long>
{
    public long Zero => 0;
    public bool IsZero(long v) => v == 0;
    public long MaxValue => long.MaxValue;
    public long Add(long v1, long v2) => v1 + v2;
    public long Sub(long v1, long v2) => v1 - v2;
    public int Compare(long x, long y) => x.CompareTo(y);
}

@takytank
Copy link
Contributor

#33 のFlowも合わせて、このinterfaceで統一するのはありかなと思っています。

@key-moon
Copy link
Contributor Author

ベンチマークありがとうございます。かなり速度に差が出ないですね。
となると、IEquatable に相当するものか、IEqualityComparer に相当するものかのどちらかでの実装になりそうでしょうか。私が思いつく範囲デメリットを挙げてみます。

クラスにオペレータを持たせる(IEquatable 相当)

デメリット

  • int型など既存の型として返り値を返すことができない
  • クラスとなった時、メソッド呼び出しにオーバーヘッドが発生しうる(?, 未確認)

演算子クラスを作成する(IEqualityComparer 相当)

デメリット

  • 型引数が煩雑になる

@terry-u16
Copy link
Contributor

私は普段IEquatable相当の実装を使用しています。だいたい以下のような形です。

/// <summary>
/// モノイド
/// </summary>
/// <typeparam name="T">自分</typeparam>
public interface IMonoid<T> where T : IMonoid<T>
{
    T Identity { get; }
    T Merge(T other);
}

/// <summary>
/// 作用付きモノイド
/// </summary>
/// <typeparam name="TMonoid">作用先</typeparam>
/// <typeparam name="TMonoidWithAction">自分</typeparam>
public interface IMonoidWithAction<TMonoid, TMonoidWithAction> : IMonoid<TMonoidWithAction>
    where TMonoid : IMonoid<TMonoid>
    where TMonoidWithAction : IMonoidWithAction<TMonoid, TMonoidWithAction>
{
    TMonoid Act(TMonoid monoid);
}

public class SegmentTree<TMonoid> 
    where TMonoid : struct, IMonoid<TMonoid>
{
}

public class LazySegmentTree<TMonoid, TMonoidWithAction>
    where TMonoid : struct, IMonoid<TMonoid>
    where TMonoidWithAction : struct, IMonoidWithAction<TMonoid, TMonoidWithAction>
{
}

この実装の個人的に嬉しい点としては、上とも若干被りますが、

  • 型引数が少なくて済む(セグメント木で1つ、遅延セグメント木で2つ)
  • セグ木に載せるモノイドは (値, index) のペアや (値, 区間長) 等のペアにしたいことがあり、結局structを書くことが多い(ValueTupleでも良いですが……)
  • (元, 演算, 単位元) が1つの型としてパッケージされるので見通しが良く、意味論的にも綺麗(ここは趣味が入っています)

あたりです。

このあたりの実装はどうしても趣味が入っちゃいますね……。

@takytank
Copy link
Contributor

型引数が煩雑になる

これに関しては、結局 MFGraphInt や MFGraphLong のようにラッパークラスを提供するのであれば、使う際には特に問題にならないかと思います。

segment treeに関しては、数値型だけ決めればいいFlowやFenwickTree とはまた話が違ってきそうな気がしています。
そこは分けて、segment treeだけ場合によっては別の実装方針で、というのは駄目ですかね?

@terry-u16
Copy link
Contributor

これに関しては、結局 MFGraphInt や MFGraphLong のようにラッパークラスを提供するのであれば、使う際には特に問題にならないかと思います。

その点については同じことを考えていました。
ジェネリック版を非公開にする(or Internalとして公開はするが使用は自己責任)のであれば特に問題ないかな、と個人的には思っています。

@key-moon
Copy link
Contributor Author

型引数が多い問題に関しては、内部の利用で留めればいいというのは確かだと思います。Flow 周りはこの実装でやるのが良さそうですね。

話は少し変わりますが、FenwickTree はアーベル群に対する作用として考えられるので、SegTree と同じ実装方針にしたいなという気持ちはありました。

IEquatable 相当の実装した Monoid がクラスだった際に展開が行われずパフォーマンスが損なわれるかも、ということが気になっているのですが、class として実装したい場面はありますかね? セグ木を使用する側の実装で同じオブジェクトを使用していて class でないと不具合が生じる、などが考えられはしますが、そこまで多くはなさそう?

@terry-u16
Copy link
Contributor

structではなくclassにしたい場面というと、

  • サイズが大きい(コピーコスト問題。16byteくらいが境界?)
  • 継承させてポリモーフィズムを実現したい
  • Mutableにしたい
  • 参照型であることを使って同一のインスタンスをいじりたい(具体例が思い付かず……)

あたりが思い浮かぶところでしょうか?競プロでセグ木を使いたい、という文脈で言うなら、使用側としてはそこまで困ることはないのかな……?と思っています。(パッと思い付けてないだけかもしれません)

逆にclassの場合、セグ木内でマージ処理をする際に素直に書くと毎回newによるヒープ確保が生じるのが気になるところかな……と思います。

@kzrnm
Copy link
Owner

kzrnm commented Sep 11, 2020

同様の手法について調べたところ、3年前から検討が続いてる言語仕様がまさに 演算子クラスを作成する(IEqualityComparer 相当) に近い手法でした。
C# 10.0 がターゲットなので採用されるのは最速でも来年以降ではありますが、近い手法を採用するのはメリットがあるかと思います。

dotnet/csharplang#110
https://github.com/MattWindsor91/roslyn/blob/master/concepts/docs/csconcepts.md

class として実装すると展開されないのはこちらでもたぶん同じです。

もう一つ、IEqualityComparer 相当のメリットとしてはC++版のsegtree, lazy_segtreeは関数と初期値を渡す実装になっているので、segtreeについてはC++版と近い形式になるというのがあります。(逆に、fenwick_treeは追加の型引数が必要となってしまいますが)

@takytank
Copy link
Contributor

もう一つ、IEqualityComparer 相当のメリットとしてはC++版のsegtree, lazy_segtreeは関数と初期値を渡す実装になっているので、segtreeについてはC++版と近い形式になるというのがあります。

こういうイメージでしょうか?
(C++に合わせて、IdentityとOperateのinterfaceも分ける……?)

public interface IOperator<T>
{
	T Identity { get; }
	T Operate(T x, T y);
}

public class SegmentTree<TValue, TOp>
	where TOp : struct, IOperator<TValue>
{
	static readonly TOp op;
	//省略
}

public readonly struct IntRangeMaxQueryOperator : IOperator<int>
{
	public int Identity => 0;
	public int Operate(int x, int y) => System.Math.Max(x, y);
}

public class Program
{
	static void Main()
	{
		var seg = new SegmentTree<int, IntRangeMaxQueryOperator>();
	}
}

おそらく使う側で IOperator の struct を定義しないといけないケースが多々出て来ると思いますが、コンストラクタにラムダ式を渡すのと同じようなものだし大丈夫ですかね……?

@kzrnm
Copy link
Owner

kzrnm commented Sep 13, 2020

本ライブラリとは特に関係ないですが、INumOperator<T> についての解説をQiitaに書きました。

https://qiita.com/naminodarie/items/76b40e03637271477f40

@kzrnm kzrnm mentioned this issue Jan 10, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants