動的に割り当てられたアレイの理想的な成長率はどれくらいですか?


83

C ++にはstd :: vectorがあり、JavaにはArrayListがあり、他の多くの言語には独自の形式の動的に割り当てられた配列があります。動的配列のスペースが不足すると、動的配列はより大きな領域に再割り当てされ、古い値が新しい配列にコピーされます。このようなアレイのパフォーマンスの中心となる問題は、アレイのサイズがどれだけ速く成長するかです。常に現在のプッシュに合うだけの大きさに成長する場合は、毎回再割り当てすることになります。したがって、配列サイズを2倍にするか、たとえば1.5倍にするのが理にかなっています。

理想的な成長因子はありますか?2倍?1.5倍?理想とは、数学的に正当化され、パフォーマンスと無駄なメモリのバランスをとることを意味します。理論的には、アプリケーションにプッシュの潜在的な分布がある可能性があることを考えると、これはアプリケーションにいくらか依存していることを理解しています。しかし、「通常」最高の値があるのか​​、それとも厳しい制約の中で最高と見なされる値があるのか​​知りたいです。

これに関する論文がどこかにあると聞きましたが、見つけることができませんでした。

回答:


43

それは完全にユースケースに依存します。データのコピー(および配列の再割り当て)に費やされる時間や余分なメモリについてもっと気にしますか?アレイはどのくらい持続しますか?それが長く続くことはないのであれば、より大きなバッファを使用することは良い考えかもしれません-ペナルティは短命です。それがぶらぶらしている場合(たとえば、Javaで、より古い世代に移行する場合)、それは明らかにペナルティです。

「理想的な成長因子」というものはありません。理論的にはアプリケーションに依存するだけでなく間違いなくアプリケーションに依存します。

2はかなり一般的な成長因子です-私はArrayListそれList<T>が.NETで使用されているものであると確信しています。ArrayList<T>Javaでは1.5を使用します。

編集:Erichが指摘しているようにDictionary<,>、.NETでは、ハッシュ値をバケット間で合理的に分散できるように、「サイズを2倍にしてから次の素数に増やす」を使用しています。(私は最近、素数がハッシュバケットの配布にそれほど優れていないことを示唆するドキュメントを見たと確信していますが、それは別の答えの議論です。)


102

少なくともC ++に適用される場合、1.5が2よりも好まれる理由を何年も前に読んだことを覚えています(これは、ランタイムシステムがオブジェクトを自由に再配置できるマネージド言語にはおそらく当てはまりません)。

理由はこれです:

  1. 16バイトの割り当てから始めたとします。
  2. さらに必要な場合は、32バイトを割り当ててから、16バイトを解放します。これにより、メモリに16バイトの穴が残ります。
  3. さらに必要な場合は、64バイトを割り当て、32バイトを解放します。これにより、48バイトの穴が残ります(16と32が隣接している場合)。
  4. さらに必要な場合は、128バイトを割り当て、64バイトを解放します。これにより、112バイトの穴が残ります(以前のすべての割り当てが隣接していると想定)。
  5. などなど。

アイデアは、2倍の拡張では、結果として生じる穴が次の割り当てに再利用するのに十分な大きさになる時点はないということです。1.5倍の割り当てを使用すると、代わりに次のようになります。

  1. 16バイトから始めます。
  2. さらに必要な場合は、24バイトを割り当ててから、16バイトを解放し、16バイトの穴を残します。
  3. さらに必要な場合は、36バイトを割り当て、24バイトを解放して、40バイトの穴を残します。
  4. さらに必要な場合は、54バイトを割り当ててから、36バイトを解放し、76バイトの穴を残します。
  5. さらに必要な場合は、81バイトを割り当て、54バイトを解放して、130バイトの穴を残します。
  6. さらに必要な場合は、130バイトの穴から122バイト(切り上げ)を使用します。

5
私が見つけたランダムなフォーラム投稿(objectmix.com/c/…)も同様の理由です。ポスターは、(1 + sqrt(5))/ 2が再利用の上限であると主張しています。
naaff 2009

19
その主張が正しければ、phi(==(1 + sqrt(5))/ 2)が実際に使用するのに最適な数です。
クリスジェスター-若い

1
1.5倍対2倍の論理的根拠を明らかにするので、私はこの答えが好きですが、ジョンの答えは、私が述べた方法に対して技術的に最も正しいです。過去に1.5が推奨された理由を尋ねるべきだった:p
Joseph Garvin 2010

6
FacebookはFBVectorの実装で1.5を使用しています。ここの記事では、1.5がFBVectorに最適である理由を説明しています。
csharpfolk 2014年

2
@jackmottそうです、私の答えが述べたとおりです。「これは、ランタイムシステムがオブジェクトを自由に再配置できるマネージド言語にはおそらく当てはまりません」。
クリスジェスター-若い

47

理想的には(n →∞の限界で)、それは黄金比です:ϕ = 1.618 .. ..

実際には、1.5のような近いものが必要です。

その理由は、古いメモリブロックを再利用して、キャッシュを利用し、OSが常により多くのメモリページを提供することを回避できるようにするためです。これを確実にするために解く方程式は、x n − 1 − 1 = x n + 1x nになり、その解は、大きなnに対してx = ϕに近づきます。


15

このような質問に答えるときの1つのアプローチは、広く使用されているライブラリが少なくとも恐ろしいことをしていないという仮定の下で、単に「ごまかして」人気のあるライブラリが何をするかを調べることです。

したがって、非常にすばやくチェックすると、Ruby(1.9.1-p129)は配列に追加するときに1.5xを使用しているように見え、Python(2.6.2)は1.125xと定数(in Objects/listobject.c)を使用しています。

/* This over-allocates proportional to the list size, making room
 * for additional growth.  The over-allocation is mild, but is
 * enough to give linear-time amortized behavior over a long
 * sequence of appends() in the presence of a poorly-performing
 * system realloc().
 * The growth pattern is:  0, 4, 8, 16, 25, 35, 46, 58, 72, 88, ...
 */
new_allocated = (newsize >> 3) + (newsize < 9 ? 3 : 6);

/* check for integer overflow */
if (new_allocated > PY_SIZE_MAX - newsize) {
    PyErr_NoMemory();
    return -1;
} else {
    new_allocated += newsize;
}

newsize上記は配列内の要素の数です。にnewsize追加されていることに注意してくださいnew_allocated。したがって、ビットシフトと3項演算子を使用した式は、実際には過剰割り当てを計算しているだけです。


したがって、配列をnからn +(n / 8 +(n <9?3:6))に成長させます。これは、質問の用語では、成長因子が1.25x(プラス定数)であることを意味します。
ShreevatsaR

1.125xに定数を加えたものではないでしょうか。
ジェイソンクレイトン

10

配列サイズをx。だけ大きくするとします。したがって、サイズから始めると仮定しますT。次に配列を拡張すると、そのサイズはになりますT*x。その後、などになりますT*x^2

以前に作成されたメモリを再利用できるようにすることが目標である場合は、割り当てる新しいメモリが、割り当てを解除した以前のメモリの合計よりも少ないことを確認する必要があります。したがって、次のような不等式があります。

T*x^n <= T + T*x + T*x^2 + ... + T*x^(n-2)

両側からTを取り除くことができます。だから私たちはこれを得る:

x^n <= 1 + x + x^2 + ... + x^(n-2)

非公式には、nth割り当て時に、以前に割り当て解除されたすべてのメモリをn番目の割り当てで必要なメモリ以上にして、以前に割り当て解除されたメモリを再利用できるようにする必要があります。

たとえば、3番目のステップ(つまりn=3)でこれを実行できるようにする場合は、次のようになります。

x^3 <= 1 + x 

この方程式は、すべてのxに当てはまり、0 < x <= 1.3(おおよそ)

以下のさまざまなnで得られるxを確認してください。

n  maximum-x (roughly)

3  1.3

4  1.4

5  1.53

6  1.57

7  1.59

22 1.61

成長係数はそれ2以降よりも小さくする必要があることに注意してくださいx^n > x^(n-2) + ... + x^2 + x + 1 for all x>=2


以前に割り当てを解除したメモリを、2番目の割り当てで1.5倍で再利用できると主張しているようです。これは真実ではありません(上記を参照)。誤解した場合はお知らせください。
awx 2013

2番目の割り当てでは、1.5 * 1.5 * T = 2.25 * Tを割り当てますが、それまでに行う割り当て解除の合計はT + 1.5 * T = 2.5 * Tです。したがって、2.5は2.25よりも大きくなります。
CEGRD 2013

ああ、もっと注意深く読む必要があります。あなたが言うのは、割り当て解除されたメモリの合計は、n番目の割り当てで割り当てられたメモリよりも多くなり、n番目の割り当てで再利用できるということではありません
awx 2013

4

それは本当に依存します。一般的な使用例を分析して、最適な数を見つける人もいます。

以前に1.5x2.0x phi x、および2の累乗を使用したことがあります。


ファイ!これは使用するのに適した数値です。これから使い始めます。ありがとう!+1
クリスジェスター

わからない…なんでファイ?これに適した特性は何ですか?
ジェイソンクレイトン

4
@Jason:phiはフィボナッチ数列を作成するため、次の割り当てサイズは現在のサイズと前のサイズの合計になります。これにより、1.5より速く、2ではない中程度の成長率が可能になります(少なくとも管理されていない言語では、2以上が適切でない理由については私の投稿を参照してください)。
クリスジェスター-若い

1
@ジェイソン:また、私の投稿へのコメント投稿者によると、ファイ以上の数字は実際には悪い考えです。私はこれを確認するために自分で計算をしていませんので、一粒の塩でそれを取ります。
クリスジェスター-若い

2

配列の長さ全体に分布があり、スペースの浪費と時間の浪費のどちらが好きかを示すユーティリティ関数がある場合は、最適なサイズ変更(および初期サイズ設定)戦略を確実に選択できます。

単純な定数倍数が使用される理由は、明らかに、各追加が一定時間を償却するためです。しかし、それはあなたが小さなサイズのために異なる(より大きな)比率を使うことができないという意味ではありません。

Scalaでは、現在のサイズを調べる関数を使用して、標準ライブラリハッシュテーブルのloadFactorをオーバーライドできます。奇妙なことに、サイズ変更可能な配列は2倍になります。これは、ほとんどの人が実際に行っていることです。

私は、実際にメモリ不足エラーをキャッチし、その場合に成長が少なくなるダブリング(または1.5 * ing)配列を知りません。巨大な単一の配列がある場合は、それを実行したいと思うようです。

さらに、サイズ変更可能な配列を十分に長く保持していて、時間の経過とともにスペースを優先する場合は、最初に(ほとんどの場合)劇的に過剰に割り当ててから、正確に正しいサイズに再割り当てするのが理にかなっているかもしれません。完了しました。


2

さらに2セント

  • ほとんどのコンピュータには仮想メモリがあります!物理メモリでは、プログラムの仮想メモリ内の単一の連続したスペースとして表示されるランダムなページをどこにでも置くことができます。間接参照の解決はハードウェアによって行われます。仮想メモリの枯渇は32ビットシステムでは問題でしたが、実際にはもう問題ではありません。したがって、穴を埋めることはもう問題ではありません(特別な環境を除く)。Windows 7以降、Microsoftでさえ余分な労力なしで64ビットをサポートしています。@ 2011
  • O(1)は、r > 1の係数で到達します。同じ数学的証明は、パラメーターとして2だけでなく機能します。
  • r = 1.5はで計算できるold*3/2ため、浮動小数点演算は必要ありません。(/2コンパイラーは、適切と判断した場合、生成されたアセンブリコードのビットシフトに置き換えるためです。)
  • MSVCはr = 1.5を採用したため、比率として2を使用しない主要なコンパイラが少なくとも1つあります。

誰かが言ったように、2は8よりも気分が良いです。また、2は1.1よりも気分が良いです。

私の感じでは、1.5が適切なデフォルトです。それ以外は特定の場合によります。


2
n + n/2オーバーフローを遅らせるために使用する方が良いでしょう。を使用n*3/2すると、可能な容量が半分になります。
owacoder

@owacoderTrue。しかし、n * 3が適合しないがn * 1.5が適合する場合、多くのメモリについて話します。nが32ビットの符号なしの場合、nが4G / 3のとき、n * 3はオーバーフローします。つまり、約1.333Gです。それは膨大な数です。これは、1回の割り当てで必要な大量のメモリです。要素が1バイトではなく、たとえばそれぞれ4バイトの場合はさらに多くなります。...ユースケースについて疑問に思う
Notinlist

3
それがエッジケースかもしれないのは事実ですが、エッジケースは通常噛み付くものです。より良い設計を示唆する可能性のあるオーバーフローやその他の動作を探す習慣を身につけることは、たとえそれが現在では先入観を持っているように見えても、決して悪い考えではありません。例として32ビットアドレスを取り上げます。今、私たちは64を...必要
owacoder

1

私はジョン・スキートに同意します。私の理論製作者の友人でさえ、係数を2xに設定すると、これがO(1)であることが証明できると主張しています。

CPU時間とメモリの比率はマシンごとに異なるため、係数も同じように異なります。ギガバイトのRAMを搭載し、CPUが低速のマシンを使用している場合、要素を新しい配列にコピーすることは、メモリが少なくなる可能性がある高速のマシンよりもはるかにコストがかかります。これは、理論的には、均一なコンピューターの場合に答えることができる質問ですが、実際のシナリオではまったく役に立ちません。


2
詳述すると、配列サイズを2倍にすると、動機付けられたO(1)挿入が得られます。要素を挿入するたびに、古い配列からも要素をコピーするという考え方です。あなたは、サイズの配列考えてみましょうメートルをして、m個のその中の要素。要素m + 1を追加する場合、スペースがないため、サイズ2mの新しい配列を割り当てます。最初のm個の要素をすべてコピーする代わりに、新しい要素を挿入するたびに1つコピーします。これにより、差異が最小限に抑えられ(メモリの割り当てを節約)、2mの要素を挿入すると、古い配列からすべての要素がコピーされます。
hvidgaard 2014年

-1

古い質問だとは思いますが、誰もが見逃しているように思われることがいくつかあります。

最初は、これは2の乗算である:サイズ<< 1。これは乗算であるものINT(フロート(サイズ)* x)は、xは、*は小数点演算を数フローティングされ、プロセッサが有する:1と2の間floatとintの間でキャストするための追加の命令を実行します。言い換えると、マシンレベルでは、ダブリングは新しいサイズを見つけるために1つの非常に高速な命令を必要とします。1から2の間の何かを掛けるには、少なくともサイズをfloatにキャストする1つの命令、乗算する1つの命令(float乗算であるため、4倍または8倍ではないにしても、おそらく少なくとも2倍のサイクルが必要です)、およびintにキャストバックする1つの命令。これは、プラットフォームが特殊レジスターの使用を要求する代わりに、汎用レジスターで浮動小数点演算を実行できることを前提としています。つまり、各割り当ての計算には、単純な左シフトの少なくとも10倍の時間がかかると予想する必要があります。ただし、再割り当て中に大量のデータをコピーする場合は、それほど大きな違いはない可能性があります。

第二に、おそらく大きなキッカーです。誰もが、解放されているメモリがそれ自体に隣接しているだけでなく、新しく割り当てられたメモリにも隣接していると想定しているようです。すべてのメモリを自分で事前に割り当ててからプールとして使用しない限り、これはほぼ確実に当てはまりません。OSは時々最終的にはこれを実行しますが、ほとんどの場合、十分な空き領域の断片化が発生するため、適切なメモリ管理システムであれば、メモリがぴったり収まる小さな穴を見つけることができます。本当にビットチャンクに到達すると、連続したピースになってしまう可能性が高くなりますが、それまでに、割り当てが十分に大きくなり、それが問題になるほど頻繁に実行されなくなります。要するに、理想的な数を使用すると空きメモリスペースを最も効率的に使用できると想像するのは楽しいですが、実際には、プログラムがベアメタルで実行されていない限り(OSがない場合のように)それは起こりません。その下ですべての決定を行います)。

質問に対する私の答えは?いいえ、理想的な数はありません。これはアプリケーション固有であるため、実際に試す人は誰もいません。あなたの目標が理想的なメモリ使用量である場合、あなたはほとんど運が悪いです。パフォーマンスについては、割り当ての頻度が少ない方が良いですが、それだけで実行すると、4または8を掛けることができます。もちろん、Firefoxが1回のショットで1GBから8GBにジャンプすると、人々は文句を言うので、それは意味がありません。これが私が通り抜ける経験則です:

メモリ使用量を最適化できない場合でも、少なくともプロセッササイクルを無駄にしないでください。2を掛けると、浮動小数点演算よりも少なくとも1桁速くなります。大きな違いはないかもしれませんが、少なくともある程度の違いはあります(特に早い段階で、より頻繁でより小さな割り当ての間)。

考えすぎないでください。すでに行われていることを行う方法を理解するために4時間費やしただけの場合は、時間を無駄にしているだけです。正直なところ、* 2よりも優れたオプションがあったとしたら、それは数十年前にC ++ベクトルクラス(および他の多くの場所)で行われていたでしょう。

最後に、本当に最適化したい場合は、小さなものに汗を流さないでください。今日では、組み込みシステムで作業していない限り、4KBのメモリが無駄になることを気にする人は誰もいません。それぞれ1MBから10MBの間にある1GBのオブジェクトに到達した場合、2倍にするのは多分多すぎます(つまり、100から1,000オブジェクトの間です)。予想される膨張率を見積もることができれば、ある時点で線形成長率に平準化することができます。1分あたり約10個のオブジェクトが予想される場合は、1ステップあたり5〜10個のオブジェクトサイズ(30秒から1分に1回)で拡張することでおそらく問題ありません。

結局のところ、考えすぎないで、できることを最適化し、必要に応じてアプリケーション(およびプラットフォーム)に合わせてカスタマイズすることです。


11
もちろんn + n >> 1と同じ1.5 * nです。あなたが考えることができるすべての実用的な成長因子のために同様のトリックを思い付くのはかなり簡単です。
ビョルンLindqvist

これは良い点です。ただし、ARMの外部では、これにより命令の数が少なくとも2倍になることに注意してください。(add命令を含む多くのARM命令は、引数の1つでオプションのシフトを実行できるため、例を1つの命令で機能させることができます。ただし、ほとんどのアーキテクチャではこれを実行できません。)いいえ、ほとんどの場合、数を2倍にします。 1から2への命令の変更は重要な問題ではありませんが、計算がより複雑な、より複雑な成長要因の場合、機密性の高いプログラムのパフォーマンスに違いが生じる可能性があります。
Rybec Arethdar 2018年

@ Rybec-1つまたは2つの命令によるタイミングの変動に敏感なプログラムもあるかもしれませんが、動的再割り当てを使用するプログラムがそれを懸念する可能性はほとんどありません。タイミングを細かく制御する必要がある場合は、代わりに静的に割り当てられたストレージを使用している可能性があります。
owacoder

私はゲームをしていますが、1つか2つの指示が間違った場所でパフォーマンスに大きな違いをもたらす可能性があります。とは言うものの、メモリ割り当てが適切に処理されている場合、いくつかの命令が違いを生むほど頻繁に発生することはないはずです。
RybecArethdar19年
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.