Skip to content

feat(value): Frozen object storage (boxed-slice) for compact parsed objects - #787

Merged
Anand Krishnamoorthi (anakrish) merged 3 commits into
microsoft:mainfrom
anakrish:frozen-object-storage
Aug 18, 2026
Merged

feat(value): Frozen object storage (boxed-slice) for compact parsed objects#787
Anand Krishnamoorthi (anakrish) merged 3 commits into
microsoft:mainfrom
anakrish:frozen-object-storage

Conversation

@anakrish

@anakrish Anand Krishnamoorthi (anakrish) commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR introduces a Frozen storage tier for Object (the abstraction backing Value::Object, already merged in #736), reducing steady-state memory for parsed JSON/Rego documents where objects are built once and read many times.

Storage tiering

Object's internal Repr now has three variants:

  • Empty — no entries, zero heap allocation.
  • Frozen(Box<[(Value, Value)]>) — a single, tightly-packed, sorted boxed slice. This is the compact tier used for objects that are read-only at the time of freezing (e.g. right after JSON/Rego parsing). No BTreeMap node/pointer/allocator overhead per entry.
  • BTree(BTreeMap<Value, Value>) — the general-purpose mutable tier, used while an object is actively being built or mutated (inserts/removes) before it is frozen.

This is purely an internal change behind the existing opaque Object newtype — no public API changes. Object/Value equality, ordering, iteration, and serialization behavior are unchanged; only the physical storage representation differs.

Freeze / thaw lifecycle

  • Objects are frozen at construction: every Object is compacted to the Frozen tier when it becomes an immutable Value (via Object::into_value / Object::freeze). During JSON/Rego deserialization this happens bottom-up — each nested object is frozen as it is parsed and then inserted into its (already frozen) parent, so a fully parsed document (Value::from_json_str, engine input/data) ends up entirely Frozen with no separate top-down pass. Because freezing is a byproduct of construction, transient values computed during evaluation are left in their natural mutable tier rather than being eagerly re-frozen.
  • An object thaws (converts from Frozen to BTree) automatically on first structural mutation (insert of a new key, or remove). Overwriting the value of an existing key (including via get_mut) does not thaw — it mutates in place and the object stays Frozen.
  • Re-freezing (Object::freeze) after a batch of mutations re-compacts back down to the Frozen tier.

Memory motivation

On a SARIF-shaped memory-residency measurement (amplified workload, MULT=50, see tests/sarif_memory.rs), the Frozen storage tier showed roughly a 4.86x reduction in live heap residency for the parsed document versus the previous BTreeMap-only baseline. tests/sarif_memory.rs is included as ongoing regression coverage: it loads the committed fixture tests/data/sarif_memory/{input.json, policy.rego} (~0.37 MiB), runs at MULT=1 by default, and can be amplified in memory via the MULT env var.

Object key interning

While parsing JSON (Value::from_json_str / from_json_file), object keys are now deduplicated through a per-parse, thread-local interning table (installed via an RAII guard for the duration of the parse only — no thread-lifetime cache). A homogeneous array of N objects sharing K keys allocates K key strings instead of N * K, with each repeated key becoming a reference-count clone of a single Rc<str>. Only keys are interned; string values are left untouched (their cardinality is unbounded). The module is std-only and compiles to no-ops under no_std. On the SARIF fixture this further reduces key-string residency for the repeated schema keys.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces compact frozen object storage to reduce memory usage for read-mostly policy data.

Changes:

  • Adds Empty, Inline, Frozen, and BTree object representations.
  • Freezes objects at parsing, evaluation, and engine boundaries.
  • Adds storage lifecycle and SARIF memory tests.

Reviewed changes

Copilot reviewed 16 out of 22 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
Cargo.toml Adds SmallVec dependency.
Cargo.lock Updates root dependencies.
bindings/ffi/Cargo.lock Updates FFI lockfile.
bindings/java/Cargo.lock Updates Java lockfile.
bindings/python/Cargo.lock Updates Python lockfile.
bindings/wasm/Cargo.lock Updates WASM lockfile.
src/value/object/mod.rs Implements tiered object storage.
src/value/object/iter.rs Supports iterating each storage tier.
src/value/object/serde.rs Freezes deserialized objects.
src/value/mod.rs Adds recursive freezing.
src/value/tests.rs Tests storage lifecycle behavior.
src/engine.rs Freezes input and merged data.
src/interpreter.rs Freezes interpreter results.
src/rvm/vm/comprehension.rs Freezes RVM comprehension results.
src/rvm/program/serialization/value.rs Freezes deserialized RVM objects.
src/builtins/objects.rs Freezes object builtin results.
src/builtins/azure_policy/template_functions_misc.rs Uses frozen object construction.
src/builtins/azure_policy/template_functions_collection.rs Uses frozen object construction.
tests/sarif_memory.rs Adds memory-residency measurement.
tests/data/sarif_memory/input.json Provides SARIF workload fixture.
tests/data/sarif_memory/policy.rego Provides workload policy.
tests/opa.rs Cleans stale OPA checkouts.
Suppressed comments (1)

src/value/object/mod.rs:177

  • Every recursive freeze calls Object::iter_mut, so this unconditional thaw converts each already-Frozen object into Inline/BTree storage, allocates BTree nodes for larger objects, and then immediately converts it back to a boxed slice. Thus set_input on a parsed document incurs a full-document thaw/refreeze cycle and a large transient peak. Add a Frozen mutable-slice iterator so value-only traversal can preserve compact storage.
        self.thaw();

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/value/object/mod.rs Outdated
Comment thread Cargo.toml Outdated
Comment thread src/value/mod.rs Outdated
Comment thread src/value/object/mod.rs Outdated
Comment thread src/value/object/mod.rs Outdated
Comment thread tests/sarif_memory.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 26 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

tests/sarif_memory.rs:217

  • This bound deliberately allows the historical unoptimized BTreeMap baseline (~30×) to pass, so reverting the PR's core memory improvement would not fail this claimed regression test. Add a platform-calibrated bound below that baseline or a deterministic allocation/storage assertion that protects the new compact behavior.
    // Coarse sanity bound: even an unoptimized BTreeMap-backed Object should
    // not balloon to more than ~50× the JSON size for the un-amplified case
    // (MULT=1). The historical baseline (MULT=50, BTreeMap) was ~30× live
    // blow-up; we leave plenty of headroom for the unamplified case so this
    // assert only catches genuinely catastrophic regressions.

src/value/mod.rs:393

  • Object keys may be any Value (including arrays/objects), but this branch recursively visits only values. Consequently, objects nested in keys remain mutable-tier storage at every advertised freeze boundary. Rebuild/transform entries while recursively freezing both keys and values, then refreeze the outer object.
                if let Some(object) = Rc::get_mut(object) {
                    object.for_each_value_mut_preserving_storage(|value| {
                        value.freeze_recursive_impl(next_depth);
                    });
                    object.refreeze_in_place();

src/value/object/mod.rs:185

  • iter_mut exposes immutable keys and mutable values only, so thawing here is unnecessary; for_each_value_mut_preserving_storage already mutates the same Frozen value slots safely. This converts parsed objects to BTree during callers such as RVM template updates (src/rvm/vm/dispatch.rs:516), defeating compact storage for a value-only mutation. Add a Frozen arm to IterMutInner and iterate the boxed slice directly.
    pub fn iter_mut(&mut self) -> IterMut<'_> {
        // Frozen storage is read-only, so thaw it in place before handing out
        // mutable element references. After this, repr is never Frozen.
        self.thaw();

src/value/interning.rs:42

  • The table retains two copies of every distinct key for the whole parse: the Rc<str> allocation and a separate owned String used as the map key. High-cardinality JSON objects therefore add key bytes instead of saving them. Key the table by the ref-counted string itself so lookup still borrows &str but only one string allocation is retained.
use alloc::string::{String, ToString as _};
use core::cell::RefCell;
use std::collections::HashMap;

/// A `String -> Rc<str>` interning table scoped to a single deserialization call.

src/value/tests.rs:1045

  • This setup cannot verify the depth guard: nest constructs every node through Value::from(BTreeMap), which now freezes each object immediately, and the assertion inspects only the already-Frozen root. The test still passes if recursive traversal stops immediately or ignores the limit without overflowing. Build an explicitly unfrozen chain and inspect representations immediately before and after MAX_MERGE_DEPTH.
    let mut value = nest(
        super::MAX_MERGE_DEPTH + 512,
        Value::from_json_str("{}").unwrap(),
    );

Comment thread src/engine.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 26 changed files in this pull request and generated no new comments.

Suppressed comments (8)

src/value/tests.rs:1045

  • This depth-limit test cannot observe the limit: nest constructs every layer through Value::from(BTreeMap), which now freezes each object immediately, and the only assertion checks that already-Frozen top layer. Construct an unfrozen chain directly and verify that a layer within the cap becomes Frozen while a layer beyond the cap remains Inline; otherwise an unbounded or no-op traversal both pass this test.
    let mut value = nest(
        super::MAX_MERGE_DEPTH + 512,
        Value::from_json_str("{}").unwrap(),
    );

src/value/object/mod.rs:185

  • iter_mut only exposes mutable values, so it cannot change key ordering and does not need to thaw the object. Thawing every frozen object here turns compact objects with more than two entries into a BTreeMap allocation and permanently loses the compact representation; this is especially costly for RVM object templates, which call iter_mut on cloned frozen literals. Add a Frozen-backed mutable iterator, as get_mut and for_each_value_mut_preserving_storage already demonstrate is safe.
        // Frozen storage is read-only, so thaw it in place before handing out
        // mutable element references. After this, repr is never Frozen.
        self.thaw();

src/engine.rs:403

  • With allocator-memory-limits enabled, this newly allocates compact slices (and can rebuild sets) without any way to report a limit violation, so set_input can exceed the configured ceiling and still succeed. The previous implementation performed no allocation here. Avoid compaction while a limit is active, or add a fallible input-setting path that checks the limit before committing.
    pub fn set_input(&mut self, mut input: Value) {
        // Keep this public API infallible: recursive freeze may allocate compact storage, but
        // there is no `Result` path here to report allocator-limit failures without an API break.
        input.freeze_recursive();

tests/sarif_memory.rs:195

  • PEAK was last reset before policy/input parsing, so this value is the peak of the whole measured run, not the peak during evaluation as reported below. Reset the peak after set_input and use that snapshot as the evaluation baseline.
    let results = engine
        .eval_query(
            "data.staticAnalysisResult.Verification.compliant".to_string(),
            false,
        )

src/builtins/azure_policy/template_functions_misc.rs:92

  • The exact memory check runs only after the entire output has been accumulated. Each entry.into_value() now allocates frozen storage, so a large input object can overshoot the configured ceiling for the whole loop before the error is observed. Add the throttled check inside the accumulation loop and retain the final exact check.
        result.push(entry.into_value());
    }
    crate::utils::limits::enforce_memory_limit().map_err(anyhow::Error::new)?;

src/value/object/mod.rs:47

  • The representation now has four variants, not three: Empty, Inline, Frozen, and BTree. The public Object documentation should describe all four tiers consistently with the module-level documentation.
/// Backed by a three-variant representation: mutable small objects build in a
/// boxed inline `SmallVec` (kept sorted by `Value::Ord`), mutable larger ones
/// promote to `BTreeMap`, and read-mostly objects freeze to an exact-size boxed
/// slice. The representation is private so it can change without touching call
/// sites.

src/value/mod.rs:383

  • Every uniquely owned set is torn down and rebuilt even when it contains only scalar values, where freezing has nothing to do. This needlessly reallocates and reinserts the entire set at every recursive freeze boundary (including RVM set-comprehension completion). Avoid rebuilding sets that contain no descendable containers, or use a traversal strategy that only reconstructs when an element actually needs compaction.
                    let old = core::mem::take(values);
                    *values = old
                        .into_iter()
                        .map(|mut value| {
                            value.freeze_recursive_impl(next_depth);
                            value
                        })
                        .collect();

src/value/tests.rs:1018

  • This does not exercise recursive freezing: Object::into_value() now freezes inner before it is inserted into the set, and the array/root use the same freezing conversion. Consequently, every object is already Frozen before freeze_recursive() runs, so the assertion would pass if recursion were removed. Build the nested Value::Object nodes directly from Rc::new(Object) in this test and assert an Inline precondition before calling freeze_recursive.

This issue also appears on line 1042 of the same file.

    let mut set = alloc::collections::BTreeSet::new();
    set.insert(inner.clone().into_value());

    let mut outer = Object::new();
    outer.insert(

@anakrish
Anand Krishnamoorthi (anakrish) force-pushed the frozen-object-storage branch 4 times, most recently from 4c0529f to a619dbf Compare August 18, 2026 12:35
@anakrish Anand Krishnamoorthi (anakrish) changed the title refactor(value): Frozen object storage (Inline + boxed-slice) for compact parsed objects feat(value): Frozen object storage (boxed-slice) for compact parsed objects Aug 18, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

tests/sarif_memory.rs:220

  • This bound does not regress the Frozen optimization: the comment records the old BTreeMap baseline at roughly 30×, while the assertion allows 50×. Reverting the compact tier would therefore still pass. Add a stable representation-focused assertion for recursively parsed objects or a platform-calibrated bound that fails on the documented baseline.
    let max_blowup = 50.0;
    assert!(
        value_blowup < max_blowup,

src/value/object/mod.rs:164

  • iter_mut cannot structurally modify keys, but it currently performs an O(n log n) thaw and leaves the object in BTree storage for value-only updates. This also affects the RVM object-template update path in src/rvm/vm/dispatch.rs:516. Add a Frozen variant backed by core::slice::IterMut so mutable value iteration preserves compact storage, consistent with get_mut.
        // Frozen storage is read-only, so thaw it in place before handing out
        // mutable element references. After this, repr is never Frozen.
        self.thaw();

src/value/object/mutate.rs:126

  • This arm thaws every frozen object even when key already exists. In that case the API only exposes a value mutation, so common get_or_insert_with call sites unnecessarily rebuild the compact boxed slice as a BTreeMap and permanently lose the Frozen tier. Probe the slice first and thaw only when the key is absent.
            Repr::Frozen(_) => {
                let repr = core::mem::take(self);
                *self = match repr {
                    Repr::Frozen(v) => Object::thawed_repr(v),
                    other => other,
                };
                self.get_or_insert_with(key, default)

Comment thread src/builtins/azure_policy/template_functions_misc.rs
Comment thread src/builtins/azure_policy/template_functions_collection.rs
Introduce a compact storage representation for Object values to cut
per-object memory overhead. `Object` now uses a private `Repr` enum:

  - Empty                           no allocation
  - Inline(Box<SmallVec<[..; 2]>>)  small maps in one compact allocation
  - Frozen(Box<[(k, v)]>)           read-mostly objects packed into a boxed slice
  - BTree(BTreeMap)                 large, mutable ordered maps

Objects deserialized/constructed as data are frozen into boxed slices; the
first structural mutation thaws back to an inline SmallVec (<= INLINE_CAP)
or BTreeMap. Value-overwrite and get_mut of an existing key stay frozen
(value-only mutation preserves storage). Call sites that mutate objects
(builtins, engine, interpreter, rvm, azure_policy templates) are adapted to
the freeze/thaw boundary. This is an internal representation change with no
public API changes.

Freezing is depth-bounded (MAX_MERGE_DEPTH) to prevent a stack overflow on
adversarially nested data that reaches freezing via programmatic
construction. Iteration uses a self-owned, key-based ObjectCursor that
resumes correctly across storage transitions.

The object module is split into cohesive sub-300-line files
(mod/mutate/freeze/cursor/iter/serde). Adds freeze/thaw unit tests
(src/value/tests.rs) and SARIF/corpus memory-residency integration tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ac07e53-88fc-4050-bd56-c0d9761b911b
A stale/partial OPA checkout (left by a cancelled run or restored from a
build cache) can exist under target/opa/branch/<ver> without a `.git`
directory. `git clone` refuses to clone into a non-empty directory, so the
harness failed with "destination path already exists and is not an empty
directory", breaking the debug/release/musl CI test jobs. Remove any such
stale directory before cloning.

Cherry-picked from microsoft#789.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ac07e53-88fc-4050-bd56-c0d9761b911b
Deduplicate object *keys* while parsing JSON in `Value::from_json_str`
(and thus `from_json_file`). A homogeneous array of N objects that share
K keys now allocates K key strings instead of N * K; every repeated key
becomes a reference-count clone of a single `Rc<str>`.

Interning is scoped to a single parse via a thread-local table installed
by an RAII `InternGuard`, so there is no thread-lifetime cache: the table
is torn down when the parse completes (including on the error path).
Only keys are interned -- string *values* are left untouched since their
cardinality is unbounded. The new `interning` module is std-only; under
`no_std` the hooks compile to no-ops.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 9ac07e53-88fc-4050-bd56-c0d9761b911b

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated no new comments.

Suppressed comments (3)

tests/sarif_memory.rs:218

  • The asserted limit is higher than the documented ~30× unoptimized BTreeMap baseline, so this test still passes if parsed objects stop using Frozen storage and the principal optimization fully regresses. Add deterministic coverage that nested objects produced by from_json_str are Frozen, or calibrate a platform-tolerant bound that actually distinguishes the optimized representation.
    // Coarse sanity bound: even an unoptimized BTreeMap-backed Object should
    // not balloon to more than ~50× the JSON size for the un-amplified case
    // (MULT=1). The historical baseline (MULT=50, BTreeMap) was ~30× live
    // blow-up; we leave plenty of headroom for the unamplified case so this
    // assert only catches genuinely catastrophic regressions.
    let max_blowup = 50.0;

src/value/object/mod.rs:164

  • iter_mut only exposes mutable values, so it cannot structurally change the object, yet this eagerly expands every Frozen object into a BTreeMap. That contradicts the freeze/thaw lifecycle used by get_mut and can discard the memory savings for parsed objects during bulk value updates. Add a Frozen IterMutInner backed by slice::IterMut and map each pair to (&key, &mut value) so value-only iteration preserves compact storage.
    pub fn iter_mut(&mut self) -> IterMut<'_> {
        // Frozen storage is read-only, so thaw it in place before handing out
        // mutable element references. After this, repr is never Frozen.
        self.thaw();

src/value/interning.rs:10

  • This overstates the allocation reduction: each key has already been allocated as an Rc<str> by serde before intern_key receives it, and duplicate candidates are then dropped. Interning reduces retained key-string allocations, not the number of key-string allocations performed during parsing; document that distinction so allocation-count and residency results are interpreted correctly.
//! keys parsed while a table is installed are deduplicated: a homogeneous array
//! of `N` objects sharing `K` keys allocates `K` key strings instead of
//! `N * K`, and every later occurrence becomes a reference-count clone. Only

@anakrish
Anand Krishnamoorthi (anakrish) marked this pull request as ready for review August 18, 2026 15:35
@anakrish
Anand Krishnamoorthi (anakrish) merged commit a7a1534 into microsoft:main Aug 18, 2026
60 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants