
2026/04/24 2:02
本稿では、Rust においてメモリ使用量を削減するために「Box(ボックスタイプ)」をどのように活用するか解説します。 - RUST ヒント:`Box<T>` はスプラッシュメモリの削減や大型構造体の配置制御に有効です。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
このテキストの主なメッセージは、Rust の構造体レイアウトを最適化することでメモリ消費量を大幅に削減できる点であり、AWS SDK プログラムで 475 MB の削減を達成したことです。これは、AWS モデルリポジトリから数千の構造体を非同期化(シリアライズ)する際に、標準的な
Option フィールドを空の場合にはボックス化されたバリエント Option<Box<T>> に置き換えることで実現されました。通常、非同期化ではこれらのオプションに対してインラインでポインタが格納され、データが存在しなくても 64 ビットシステム上では不要なメモリを消費します(例えば、Option<String> は None でも 24 バイト必要であり、空ではない構造体の場合は 140 バイトを超えることもあります)。空の場合にボックス化された構造体を使用することで、メモリフットプリントは 144 バイト以上からわずか 32 バイトに低下しました。開発者は tikv-jemallocator やプロファイリング機能といった専門ツールを利用し、Serde ライブラリを使ってこの特定のスナップショットのメモリフットプリントを測定しました。多くのボックスによるヒープのフラグメンテーションへの理論的な懸念はありますが、この具体的な実装ではそのような問題は見られず、むしろ低いメモリ圧力により高価なメモリの検索が回避されたため、パフォーマンスも向上しました。結局のところ、ユーザーは著しく小さなメモリフットプリントと高速化を得ることができ、これはメモリ制限の厳しい環境で大規模なオプション構造体を管理する Rust 開発者にとって実用的なパターンとなります。
Text to translate:
The text's primary message is that optimizing Rust struct layouts can drastically reduce memory consumption, achieving a 475 MB reduction in an AWS SDK program. This was accomplished by replacing standard
Option fields with boxed variants (Option<Box<T>>) for optional or empty structs when deserializing thousands of structures from the AWS models repository. Normally, deserialization stores inline pointers for these options, consuming unnecessary memory even when data is absent on 64-bit systems (e.g., an Option<String> takes 24 bytes regardless of being None, while an empty non-empty struct can exceed 140 bytes). By switching to boxed structures for empty cases, the memory footprint dropped from over 144 bytes to just 32 bytes. Developers utilized specialized tools like tikv-jemallocator and profiling features to measure this specific footprint using the Serde library. While there are theoretical concerns about heap fragmentation with many boxes, this specific implementation saw no such issues; in fact, performance improved because lower memory pressure eliminated expensive memory searches. Ultimately, users gain a significantly smaller memory footprint and better speed, offering a practical pattern for Rust developers managing large optional structures in memory-constrained environments.本文
現実世界の Rust プログラムで 895 MB を使用していたメモリ容量を、一部の構造体(struct)の配置や JSON ファイルのデシリアライズ方法を変更するだけで 475 MB も節約することができました。
実際のユースケース
このプログラムは、https://github.com/awslabs/aws-sdk-rust/tree/main/aws-models にあるすべての JSON ファイルを、「Smithy Shape」構造体に変換してデシリアライズします。
これらのファイルには、以下の例に似た数千人規模の構造体が含まれています:
"com.amazonaws.iam#EnableOrganizationsRootSessionsResponse": { "type": "structure", "members": { "OrganizationId": { "target": "com.amazonaws.iam#OrganizationIdType", "traits": { "smithy.api#documentation": "<p>The unique identifier (ID) of an organization.</p>" } }, "EnabledFeatures": { "target": "com.amazonaws.iam#FeaturesListType", "traits": { "smithy.api#documentation": "<p>The features you have enabled for centralized root access.</p>" } } }, "traits": { "smithy.api#output": {} } }
Rust の慣習に従い、非常に便利な
serde ライブラリを使用しています。詳細な解説は割愛しますが、明確にするために構造体の一部を示しておきます。全体を読まなくてよいので、ただいくつかの構造体が相互にネストしており、一部のフィールドが可視化(optional)で serde 属性が付与されていることを覚えておいてください:
#[derive(Clone, Deserialize, Serialize)] pub struct SmithyShape { #[serde(rename = "type")] pub shape_type: SmithyShapeType, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub operations: Vec<SmithyReference>, #[serde(default)] pub members: FxHashMap<String, SmithyReference>, #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option<SmithyReference>, #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option<SmithyReference>, #[serde(default, skip_serializing_if = "Option::is_none")] pub member: Option<SmithyReference>, #[serde(default, skip_serializing_if = "Option::is_none")] pub input: Option<SmithyReference>, #[serde(default, skip_serializing_if = "Option::is_none")] pub output: Option<SmithyReference>, #[serde(default)] pub traits: SmithyTraits, } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SmithyReference { pub target: ShortShapeId, #[serde(default)] pub traits: SmithyTraits, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct SmithyTraits { #[serde(rename = "smithy.api#title", skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(rename = "aws.api#service", skip_serializing_if = "Option::is_none")] pub service: Option<SmithyServiceTrait>, #[serde( rename = "smithy.api#sensitive", skip_serializing_if = "Option::is_none" )] pub sensitive: Option<SmithySensitiveTrait>, #[serde( rename = "smithy.api#documentation", skip_serializing_if = "Option::is_none" )] pub documentation: Option<String>, #[serde(rename = "smithy.api#pattern", skip_serializing_if = "Option::is_none")] pub pattern: Option<String>, #[serde(rename = "aws.iam#iamAction", skip_serializing_if = "Option::is_none")] pub iam_action: Option<SmithyIamAction>, } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct SmithyServiceTrait { pub sdk_id: Option<String>, pub arn_namespace: Option<String>, pub cloud_formation_name: Option<String>, pub cloud_trail_event_source: Option<String>, pub endpoint_prefix: Option<String>, }
これは標準的な見映えをしたコードであり、現在の慣行ですが、私たちはこれを「 naïve(天真的)」とも呼ぶこともできます。このようにデシリアライズすることで、構造体が 895 MB のメモリを消費してしまいます。分析の結果、大部分の可視化された文字列が欠落していることが判明し、そこで大幅なメモリフットプリント削減を実現しました。
しかし、これには Rust に特有のいくつかのポイントを知る必要があるため、少し寄り道が必要です:
Rust の構造体とメモリについて
64 ビットのプラットフォームでは、1 ワードは 8 バイトで構成されています(例え
usize を格納する場合でも)。
String は、文字列へのアドレス、割当サイズ、容量の 3 ワードが必要であり、これに加えて文字節そのものを格納するためのヒープ上の空間も必要です。つまり、実際の文字コンテンツを含まずとも、String のサイズは 24 バイトになります(dbg!(std::mem::size_of::<String>()); で確認できます)。
niche compiler optimization(ニッチなコンパイラ最適化)により、
Option<String> は同じ大きさになります(基本的にポインタ型を option にする場合、ポインタがゼロであれば None であるかどうかを確認するために追加のバイトは不要です)。
したがって、以下の構造体は、すべての文字列が欠落している(
None)場合、正確に 120 バイト(5×24)のメモリを使用します:
pub struct SmithyServiceTrait { pub sdk_id: Option<String>, pub arn_namespace: Option<String>, pub cloud_formation_name: Option<String>, pub cloud_trail_event_source: Option<String>, pub endpoint_prefix: Option<String>, }
さて、構造体の合成についてです。別の構造体を「含む」ような構造体を見てみましょう。ここでは単純化のために、当方の
SmithyServiceTrait と他のフィールドを含むものだけを想定します:
pub struct Container1 { pub some_string: Option<String>, #[serde(default)] pub trait: SmithyServiceTrait, }
最小サイズは、予想通り 24 + 120 = 144 バイトとなります。
しかし、我々の
SmithyShape は可視化された構造体だけを内包しています。Container 構造体を Option<SmithyServiceTrait> を使用するように変更するとどうなるでしょうか?
pub struct Container2 { pub some_string: Option<String>, #[serde(default)] pub trait: Option<SmithyServiceTrait>, }
両方の
some_string と trait が None の場合、コンテナのサイズは何でしょうか?それは Container1 と同じであり、option を持ってもメモリの利点は得られません(実際には、SmithyServiceTrait 内にのみ含まれる Option<String> だけがあるため、コンパイラが追加のバイトを省略できることに幸運でした)。
これを我々の
SmithyTraits に適用すると、標準的な実装がメモリ上膨れ上がる理由がわかります。これは Java や Python、JavaScript などの言語におけるクラス合成とは根本的に異なります。
これらの言語では、以下の場合:
class Container { String someString; SmithyServiceTrait trait; }
null トリート(trait)はメモリ上でポインタサイズのワードのみを使用します。
我々の Rust の
Container も何も格納するものがない場合にオプションの内容に対してのみ 1 ワードをとるようにするためには、基本的に模倣したい言語で行われていることをする必要あります:つまり、このコンテンツをコンテナの外、ヒープ上に置く必要があるのです:
pub struct Container3 { pub some_string: Option<String>, pub trait: Option<Box<SmithyServiceTrait>>, }
今、両方の
some_string と trait が None の場合、コンテナはメモリ上で 32 バイト(Option<String> 用の 3 ワード、Option<Box<...>> 用 1 ワード)のみを使用します。前に述べた niche optimization も Option<Box<...>> に適用されます:それは単純な Box<...> よりも多く消費しません。
メモリを回復させた変更
基本的には、変化は以下の点に集約されます:
- 構造体が不要である何时を検出する(すなわち、すべてのフィールドが
の時)None - その親構造体内でそれを可視化にし、ヒープ上に移動させる
- 空の不要な構造体を保存しないようにカスタムデシリアライザを実装する
したがって:
#[derive(Debug, Clone, Deserialize, Serialize)] pub struct SmithyReference { pub target: ShortShapeId, #[serde(default)] pub traits: SmithyTraits, }
は以下のように変更されます:
#[derive(Debug, Clone, Deserialize, Serialize)] pub struct SmithyReference { pub target: ShortShapeId, #[serde( default, deserialize_with = "deserialize_boxed_traits", serialize_with = "serialize_boxed_traits" )] pub traits: Option<Box<SmithyTraits>>, } fn deserialize_boxed_traits<'de, D: Deserializer<'de>>( deserializer: D ) -> Result<Option<Box<SmithyTraits>>, D::Error> { let traits = SmithyTraits::deserialize(deserializer)?; if traits.is_empty() { // すなわち、すべての可視化された文字列が none の場合 Ok(None) } else { Ok(Some(Box::new(traits))) } }
同様にして、
SmithyShape においてすべての Option<SmithyReference> を Option<Box<SmithyReference>> に置き換え、アクセサいくつかはオプションのwegen(手段)のために修正されただけで、これで完了です。こうして、デシリアライズされた全 AWS shape を格納するために必要なメモリ容量を倍減させ、475 MB の節約に成功しました。
いくつかのノート:
- このデシリアライゼーションは、オブジェクトが廃棄される前にデシリアライズされるため、CPU でより多くのコストがかかります。しかし、メモリの検索を必要としなくなったことで、この追加の手順にもかかわらずタスク全体が早まったという点において、トレードオフは完全な勝利となりました。
- 多くのボックスは破片化されたヒープを意味します。このような場合の問題ではありませんが、これは覚えておく価値があります。
検証:影響の実証
経験を持つと、どこでスペースを節約し、どれだけなのかへの直感を得ることができます。しかし、真剣に作業するためには、行ったことが機能したことを確認し、価値があったかを検証する必要があります。つまり、測定が必要です。Rust にて、すべてのポインターをたどって複合オブジェクトが消費する総空間を知る簡単な軽量な方法はありません。ここでは、割り当ての状態に関する情報を提供するアロケータを使用する解決策を選びました(標準アロケータは内部統計について限られた可視性しか提供しないため、
jemalloc を使用しました)。デシリアライゼーション前のメモリ使用量と後のメモリ使用量を比較しました。
常にこのアロケータを使用したいわけではないので、
Cargo.toml に「プロファイル」機能 Feature を定義しました:
[features] profile = ["tikv-jemallocator", "tikv-jemalloc-ctl"] [dependencies] tikv-jemallocator = { optional = true, version = "0.6", features = ["stats", "profiling"] } tikv-jemalloc-ctl = { optional = true, version="0.6", features = ["stats"] }
そして、メインで使用することを宣言します:
#[cfg(feature = "profile")] #[global_allocator] static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
次に、それらの shape をすべてデシリアライズする関数において、測定を実行します:
#[cfg(feature = "profile")] fn allocated_mb() -> usize { tikv_jemalloc_ctl::epoch::advance().unwrap(); tikv_jemalloc_ctl::stats::allocated::read().unwrap_or(0) / (1024 * 1024) } #[cfg(feature = "profile")] let base = allocated_mb(); ... shape をすべて読み込む ... #[cfg(feature = "profile")] eprintln!( "Memory used for the shapes = {} MB (total)", allocated_mb() - base );
ヒント:
tikv_jemalloc_ctl は、サーバーアプリケーションを追跡する上で興味深いかもしれないより多くの詳細を公開しています。
結論:覚えておくべきことは何か(簡潔に)
総括すると、Rust 開発者が必要として理解し、覚えておくべきものは以下の通りです:
- 複合構造体は有意なメモリを消費します。
- コンテンツが重要でないことを検出することで、フィールド(
)を可視化にすることは費用対効果があります。BigStruct - 可視化されたフィールド
は、Option<BigStruct>
でも少なくともNone
のスペースを取ります。BigStruct
でボックス化することで、この連鎖を断ち切る(その時、field: Option<Box<BigStruct>>
は親構造体でワードのみをとる)。None- これらの最適化は、Serde によるデシリアライゼーションにおいても可能にします。