雑学1473612 views
中学英語811710 views
高校倫理1440106 views
中学理科1630641 views
ヒストリア290598 views
世界の国564520 views
Computer368106 views
数学講師2886227 views
高校日本史190571 views
LaTeX962153 views
Help
Tools
NewsSpreadsheetCalendarSlidesTier ListPen ToolIllustrationCrayonPixel ArtASCII ArtPerspectiveEndless StairsGraphMind MapER DiagramFamily TreeMemeCurved TextImage EditorMosaicRetro FilterPencil SketchOCR/HighlighterMakeup EditorFaviconVideo TrimmerScrolling VideoVideo TitleColor PickerColor ExtractorBonfireFireworksWater RippleWater SplashBreaking GlassWood GrainMarble TextureCSS ButtonIcon MakerBar ChartGrouped Bar ChartStacked Bar ChartPie ChartLine ChartArea ChartStacked Area ChartScatter Plot3D Bar Chart3D Pie ChartBar Chart RaceBubble ChartPopulation PyramidPictogramEarningsCandlestick ChartInvestment RiskMortgage SimulatorCalculatorMatrix CalculatorFunction GraphPolynomial ExpansionVenn DiagramField VisualizerRubik's Cube Group TheoryTraveling SalesmanVoronoi and DelaunayFractalColumn ArithmeticDraw Math FiguresArithmetic AnimationArithmetic Word ProblemsCounting with Tree DiagramsCube NetsCross SectionsMotion PathMechanicsWavesLight and LensesThermodynamicsHow Semiconductors WorkMolecular StructuresAtomic OrbitalsElectrochemical CellsChemical EquilibriumCrystal LatticesBuffer pHComplex IonsDNA Double HelixCell DivisionMembrane ChannelsNerve ImpulseRock ClassificationWeatherConstellationsSolar and Lunar Eclipses3D ModelingFloor PlanSeismic StructuresIntersection TurnMaglevCooking AnimationOrigamiLive Viewer CountGeoJSON MapRailway MapPopulation MapCrime MapLand Price MapSchool MapShrine and Castle MapHouse of Representatives MapWord MapSolitaireReversiHakoiri MusumeChessHamburgerRippleSlide Puzzle MakerNeon PinballNovel MakerJapanese Typing PracticePiano Score EditorMusic Theory

English

Python の set のリサイズ戦略とメモリ断片化

Python の set は要素数に応じてテーブルサイズを動的に変更する。このリサイズ戦略とメモリへの影響を詳しく見ていく。

リサイズのトリガー

CPython では、テーブルの使用率が約 66% を超えるとリサイズが発生する。

/* setobject.c からの抜粋 */
#define USABLE_FRACTION(n) (((n) << 1) / 3)

/* fill が USABLE_FRACTION を超えたらリサイズ */
if (so->fill * 3 >= (so->mask + 1) * 2) {
    /* リサイズ実行 */
}

fill * 3 >= (mask + 1) * 2fill >= 2/3 * size と同等だ。

なぜ 66% なのか

オープンアドレス法では、使用率が高いと衝突が増える。66% は探索効率とメモリ効率のバランスポイント。

fill を使う理由

deleted(ダミー)も含めて計算することで、削除が多い状況での性能劣化を防ぐ。

成長戦略

リサイズ時のテーブルサイズ決定ロジックを見る。

/* 新しいサイズを決定 */
Py_ssize_t minused = so->used * 2;  /* 最低でも used の 2 倍 */

Py_ssize_t newsize = PySet_MINSIZE;
while (newsize <= minused) {
    newsize <<= 2;  /* 4 倍ずつ増やす */
}

テーブルは 4 倍ずつ成長する。これはリサイズ頻度を減らすためだ。

# 成長パターンの観察
import sys

s = set()
prev_size = sys.getsizeof(s)
print(f"  0 elements: {prev_size} bytes")

for i in range(1, 1000):
    s.add(i)
    size = sys.getsizeof(s)
    if size != prev_size:
        print(f"{i:3d} elements: {size} bytes")
        prev_size = size

典型的な出力:

#   0 elements: 216 bytes
#   5 elements: 728 bytes
#  21 elements: 2264 bytes
#  85 elements: 8408 bytes
# 341 elements: 32984 bytes

要素数 5, 21, 85, 341… でリサイズが発生している。

縮小は自動では行われない

要素を削除してもテーブルは自動的に縮小しない。

import sys

s = set(range(1000))
print(f"After adding 1000: {sys.getsizeof(s)} bytes")

for i in range(999):
    s.remove(i)
print(f"After removing 999: {sys.getsizeof(s)} bytes")

大きなテーブルが残り続ける。メモリを解放したい場合は、新しい set を作り直す必要がある。

s = set(range(1000))
for i in range(999):
    s.remove(i)

# メモリを解放するには再作成
s = set(s)
print(f"After recreation: {sys.getsizeof(s)} bytes")

rehash の過程

リサイズ時には全要素を新しいテーブルに再配置する。

static int
set_table_resize(PySetObject *so, Py_ssize_t minused)
{
    setentry *oldtable = so->table;
    Py_ssize_t oldmask = so->mask;
    
    /* 新しいテーブルを確保 */
    setentry *newtable = PyMem_New(setentry, newsize);
    
    /* 全エントリを再挿入 */
    for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
        if (entry->key != NULL && entry->key != dummy) {
            /* 新しい位置を計算して挿入 */
            set_insert_key(so, entry->key, entry->hash);
        }
    }
    
    /* 古いテーブルを解放 */
    if (oldtable != so->smalltable)
        PyMem_Free(oldtable);
}

この再配置は O(n) の操作だが、償却計算量では O(1) に収まる。

償却計算量の分析

n 回の挿入での総リサイズコストを考える。

# リサイズは 8 → 32 → 128 → 512 → 2048 ...
# 各リサイズでのコピー数: 5, 21, 85, 341, ...

# 総コピー数 ≈ n + n/4 + n/16 + n/64 + ...
#            = n * (1 + 1/4 + 1/16 + ...)
#            = n * 4/3
#            ≈ 1.33n

# n 回の挿入で 1.33n のコピー → 1 回あたり O(1)

幾何級数の和により、平均コストは定数になる。

ダミーエントリとリサイズ

削除が多いと、ダミーエントリが蓄積してリサイズが必要になる。

s = set(range(100))
print(f"Initial: {sys.getsizeof(s)} bytes")

# 大量に削除
for i in range(80):
    s.remove(i)
# テーブルサイズは変わらないが、fill は 100 のまま

# 新しい要素を追加すると...
for i in range(100, 200):
    s.add(i)
print(f"After operations: {sys.getsizeof(s)} bytes")

この場合、ダミーエントリが多いため fill が閾値を超え、リサイズが発生する。リサイズ時にダミーは除去されるため、テーブルはクリーンになる。

メモリ断片化

頻繁なリサイズはメモリ断片化を引き起こす可能性がある。

断片化の発生

テーブル確保→解放→より大きいテーブル確保を繰り返すと、メモリ空間に「穴」ができる。

glibc のアロケータ

小さいブロックは再利用されるが、大きいブロックは mmap/munmap で管理され、断片化しにくい。

Python の場合、CPython のメモリアロケータが介在するため、OS レベルの断片化は限定的だ。

smalltable の効果

8 要素以下の set は、オブジェクト内の smalltable を使う。

import sys

for n in range(10):
    s = set(range(n))
    print(f"{n} elements: {sys.getsizeof(s)} bytes")

出力:

# 0 elements: 216 bytes
# 1 elements: 216 bytes
# ...
# 4 elements: 216 bytes
# 5 elements: 728 bytes  ← ここでリサイズ

4 要素までは追加のメモリ確保が発生しない。これにより、小さい set の作成・破棄が高速になる。

メモリプロファイリング

tracemalloc を使ってメモリ使用を追跡できる。

import tracemalloc

tracemalloc.start()

sets = []
for i in range(1000):
    s = set(range(i))
    sets.append(s)

current, peak = tracemalloc.get_traced_memory()
print(f"Current: {current / 1024 / 1024:.2f} MB")
print(f"Peak: {peak / 1024 / 1024:.2f} MB")

tracemalloc.stop()

ピークメモリがカレントより大きい場合、リサイズ時の一時的なメモリ増加が原因だ。

事前サイズ確保

残念ながら、Python の set には reserve() のような事前確保の仕組みがない。

# C++ なら
# std::unordered_set<int> s;
# s.reserve(10000);

# Python ではできない
s = set()
# s.reserve(10000)  # そんなメソッドはない

ただし、既知のデータから初期化すれば、適切なサイズで作成される。

# 初期データがあれば一度にサイズが決まる
s = set(range(10000))  # リサイズは 1 回で済む

# 逐次追加だと複数回リサイズ
s = set()
for i in range(10000):
    s.add(i)  # 複数回リサイズが発生

大量のデータを扱う場合は、リスト内包表記などで一度に作成するほうが効率的だ。

長期稼働プロセスでの注意

削除と追加を繰り返す長期稼働プロセスでは、set のメモリ効率が低下することがある。

import sys

s = set()

# 追加と削除を繰り返す
for cycle in range(10):
    for i in range(10000):
        s.add(i)
    for i in range(9000):
        s.remove(i)
    print(f"Cycle {cycle}: len={len(s)}, size={sys.getsizeof(s)} bytes")

テーブルが大きいまま維持される。定期的に s = set(s) で再構築するか、アプリケーションのリスタートを検討すべきだ。

# 定期的なメンテナンス
def compact_set(s):
    """set を再構築してメモリを最適化"""
    return set(s)

このリサイズ戦略を理解することで、大規模データを扱う際のメモリ管理が適切に行える。