You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fory.apache.org by ch...@apache.org on 2026/07/27 10:36:13 UTC

(fory) branch main updated: feat(xlang): harden external type support (#3890)

This is an automated email from the ASF dual-hosted git repository.

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new ad37f0d72 feat(xlang): harden external type support (#3890)
ad37f0d72 is described below

commit ad37f0d72eed073d11cb8804548c69f84a570832
Author: Shawn Yang <sh...@gmail.com>
AuthorDate: Mon Jul 27 18:36:08 2026 +0800

    feat(xlang): harden external type support (#3890)
    
    ## Why?
    
    
    
    ## What does this PR do?
    
    
    
    ## Related issues
    
    
    
    ## AI Contribution Checklist
    
    
    
    - [ ] Substantial AI assistance was used in this PR: `yes` / `no`
    - [ ] If `yes`, I included a completed [AI Contribution
    Checklist](https://github.com/apache/fory/blob/main/AI_POLICY.md#9-contributor-checklist-for-ai-assisted-prs)
    in this PR description and the required `AI Usage Disclosure`.
    - [ ] If `yes`, my PR description includes the required `ai_review`
    summary and screenshot evidence or equivalent persisted links of the
    final clean AI review results from both fresh reviewers described in
    `AI_POLICY.md`, the Fory-guided reviewer and the independent general
    reviewer, on the current PR diff or current HEAD after the latest code
    changes.
    
    
    
    ## Does this PR introduce any user-facing change?
    
    
    
    - [ ] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    ## Benchmark
---
 .agents/languages/csharp.md                        |   5 +
 .agents/languages/dart.md                          |   5 +
 .agents/languages/swift.md                         |   5 +
 benchmarks/csharp/ExternalEquivalenceBenchmark.cs  |  25 +-
 benchmarks/csharp/ExternalEquivalenceProgram.cs    |   9 +-
 benchmarks/csharp/README.md                        |  51 ++-
 benchmarks/csharp/external_equivalence_report.py   | 285 +++++++++----
 benchmarks/csharp/run.sh                           | 134 +++++-
 csharp/README.md                                   |   1 +
 .../Fory.Generator/AnalyzerReleases.Unshipped.md   |   1 +
 csharp/src/Fory.Generator/ForyModelGenerator.cs    | 456 ++++++++++++++++++++-
 csharp/src/Fory/Attributes.cs                      |   8 +
 csharp/tests/Fory.ExternalTypes/ExternalModels.cs  |  39 +-
 csharp/tests/Fory.Tests/ExternalTypeSerializers.cs |  10 +
 csharp/tests/Fory.Tests/ForyGeneratorTests.cs      | 196 +++++++++
 csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs  |  60 +++
 dart/packages/fory/CHANGELOG.md                    |   3 +
 dart/packages/fory/README.md                       |   8 +-
 .../fory/lib/src/annotation/fory_field.dart        |  22 +-
 .../fory/lib/src/codegen/fory_generator.dart       |  94 ++++-
 .../fory/test/external_type_generator_test.dart    |  56 +++
 .../fory/test/graph_memory_budget_test.dart        |  42 ++
 .../fory/test/runtime_validation_test.dart         |  62 +--
 .../fory/test/type_spec_annotation_test.dart       |  48 +--
 docs/guide/csharp/external-types.md                |  18 +-
 docs/guide/csharp/supported-types.md               |   3 +
 docs/guide/dart/external-types.md                  |   8 +-
 docs/guide/dart/schema-metadata.md                 |  11 +-
 docs/guide/swift/external-types.md                 |   4 +
 docs/security/deserialization.md                   |  21 +
 docs/specification/xlang_implementation_guide.md   |  49 ++-
 .../org/apache/fory/xlang/CSharpXlangTest.java     |   6 +
 swift/Sources/Fory/MacroDeclarations.swift         |   4 +-
 swift/Sources/ForyMacro/ForyObjectMacro.swift      | 152 +++++--
 .../ForyMacro/ForyObjectMacroReadGeneration.swift  |  52 ++-
 .../Tests/ForyExternalModels/ExternalModels.swift  |   1 +
 .../ForyTests/ExternalTypeSerializationTests.swift |  67 +++
 swift/Tests/ForyTests/MacroDiagnosticTests.swift   | 173 ++++++++
 38 files changed, 1887 insertions(+), 307 deletions(-)

diff --git a/.agents/languages/csharp.md b/.agents/languages/csharp.md
index 07969c975..7b090d751 100644
--- a/.agents/languages/csharp.md
+++ b/.agents/languages/csharp.md
@@ -42,6 +42,11 @@ Load this file when changing `csharp/` or C# xlang behavior.
   converted, or inaccessible targets use a manual serializer. Derive
   allocation, reference publication, default value, and graph-memory behavior
   from the target class/struct kind, never the declaration kind.
+- External class graph-memory formulas count external declaration fields plus
+  public instance fields visible on the target and its base classes. An external
+  declaration field marked ignored is budget-only and must not enter wire
+  metadata or generated target access. Resolve this field set in the generator;
+  do not inspect target layout in deserialization hot paths.
 - External children compose through the concrete carrier serializers already
   owned by `TypeResolver`: nullable structs, one-dimensional arrays, List,
   LinkedList, Queue, Stack, HashSet, SortedSet, ImmutableHashSet, Dictionary,
diff --git a/.agents/languages/dart.md b/.agents/languages/dart.md
index fdb8af4b8..814b64aad 100644
--- a/.agents/languages/dart.md
+++ b/.agents/languages/dart.md
@@ -23,6 +23,11 @@ Load this file when changing `dart/`.
   statically provable reference-tracked path back to the target, including
   paths through supported list, set, and map type arguments. Do not simulate
   early publication with placeholders or final-field patching.
+- External object graph-memory formulas count all declaration fields plus
+  concrete public instance fields visible on the target, superclass, and mixin
+  storage paths. Ignored declaration fields are budget-only and must not enter
+  target access, construction, metadata, or wire code. Compute this union during
+  generation and emit one constant reservation.
 - Keep root numeric wrapper defaults separate from generated field metadata. Root wrapper resolution belongs in the builtin resolver, while annotations and generated metadata choose fixed, tagged, or declared-field encodings.
 - Dart 64-bit carriers are optimized for each platform. Do not replace native extension-type wrappers with allocation-heavy classes or route web/native hot paths through `BigInt` unless the user approves a representation change.
 - In `Buffer`, cursor, serializer, and generated-code hot paths, prefer direct byte/local integer operations and conditional import/export files over callbacks, records, holder objects, wrapper round-trips, or runtime platform branches.
diff --git a/.agents/languages/swift.md b/.agents/languages/swift.md
index c4cf4030b..74c92b6de 100644
--- a/.agents/languages/swift.md
+++ b/.agents/languages/swift.md
@@ -31,6 +31,11 @@ Load this file when changing `swift/` or Swift xlang behavior.
 - External structural serializers extend `@ForyStruct`, `@ForyEnum`, and `@ForyUnion` through
   `target:`. Because those declarations are separate from their targets, static nodes select them
   with `with`, and registration uses the existing serializer registration API.
+- Swift macros cannot inspect an external target's stored layout. External class
+  graph-memory formulas therefore count only external declaration fields;
+  ignored declaration fields are budget-only and must not enter target access,
+  construction, metadata, or wire code. Omitted large value storage must be
+  declared explicitly and ignored.
 - Direct `Any` and `AnyObject` root overloads remain disfavored forwarding facades over
   `DynamicSerializer<Any>` and `DynamicSerializer<AnyObject>`, including their Data-buffer forms.
   Arbitrary protocol roots explicitly select `DynamicSerializer<T>`. Do not add an unconstrained
diff --git a/benchmarks/csharp/ExternalEquivalenceBenchmark.cs b/benchmarks/csharp/ExternalEquivalenceBenchmark.cs
index c835b5a1e..c15c866d5 100644
--- a/benchmarks/csharp/ExternalEquivalenceBenchmark.cs
+++ b/benchmarks/csharp/ExternalEquivalenceBenchmark.cs
@@ -44,7 +44,7 @@ internal sealed record EquivalenceSideResult(
     int SerializedSize,
     string SerializedFrameBase64,
     EquivalenceMeasurement Measurement,
-    double? AllocatedBytesPerOperation);
+    double AllocatedBytesPerOperation);
 
 internal sealed record EquivalenceSideOutput(
     string GeneratedAtUtc,
@@ -138,16 +138,12 @@ internal static class ExternalEquivalenceBenchmark
             benchmarkCase.Action,
             options.DurationSeconds);
 
-        double? bytesPerOperation = null;
-        if (options.AllocationIterations > 0)
-        {
-            CollectGarbage();
-            long allocatedBytes = MeasureAllocation(
-                benchmarkCase.Action,
-                options.AllocationIterations);
-            bytesPerOperation =
-                (double)allocatedBytes / options.AllocationIterations;
-        }
+        CollectGarbage();
+        long allocatedBytes = MeasureAllocation(
+            benchmarkCase.Action,
+            options.AllocationIterations);
+        double bytesPerOperation =
+            (double)allocatedBytes / options.AllocationIterations;
 
         return new EquivalenceSideResult(
             benchmarkCase.DataType,
@@ -582,11 +578,8 @@ internal static class ExternalEquivalenceBenchmark
             Console.WriteLine(
                 $"{result.DataType}/{result.Operation}: "
                 + $"{result.Measurement.AverageNanoseconds:N1} ns/op");
-            if (result.AllocatedBytesPerOperation.HasValue)
-            {
-                Console.WriteLine(
-                    $"  allocated={result.AllocatedBytesPerOperation:N2} B/op");
-            }
+            Console.WriteLine(
+                $"  allocated={result.AllocatedBytesPerOperation:N2} B/op");
         }
     }
 
diff --git a/benchmarks/csharp/ExternalEquivalenceProgram.cs b/benchmarks/csharp/ExternalEquivalenceProgram.cs
index 3933395fc..743e21750 100644
--- a/benchmarks/csharp/ExternalEquivalenceProgram.cs
+++ b/benchmarks/csharp/ExternalEquivalenceProgram.cs
@@ -29,6 +29,8 @@ internal enum ExternalImplementation
 
 internal sealed class ExternalBenchmarkOptions
 {
+    public const int DefaultAllocationIterations = 100_000;
+
     public HashSet<string> DataFilter { get; init; } = [];
 
     public double WarmupSeconds { get; init; } = 1.0;
@@ -39,7 +41,7 @@ internal sealed class ExternalBenchmarkOptions
 
     public ExternalImplementation Implementation { get; init; }
 
-    public int AllocationIterations { get; init; }
+    public int AllocationIterations { get; init; } = DefaultAllocationIterations;
 
     public bool ShowHelp { get; init; }
 
@@ -50,7 +52,7 @@ internal sealed class ExternalBenchmarkOptions
         double durationSeconds = 3.0;
         string outputPath = "external_equivalence_results.json";
         ExternalImplementation implementation = ExternalImplementation.None;
-        int allocationIterations = 0;
+        int allocationIterations = DefaultAllocationIterations;
         bool showHelp = false;
 
         for (int i = 0; i < args.Length; i++)
@@ -225,7 +227,8 @@ internal static class ExternalEquivalenceProgram
         Console.WriteLine("  --duration <seconds>");
         Console.WriteLine("  --output <path>");
         Console.WriteLine("  --external-implementation <ordinary|external>");
-        Console.WriteLine("  --allocation-iterations <count>");
+        Console.WriteLine(
+            $"  --allocation-iterations <count> (default: {ExternalBenchmarkOptions.DefaultAllocationIterations})");
         Console.WriteLine("  --help");
     }
 }
diff --git a/benchmarks/csharp/README.md b/benchmarks/csharp/README.md
index 63f17b5b9..0999a1379 100644
--- a/benchmarks/csharp/README.md
+++ b/benchmarks/csharp/README.md
@@ -38,6 +38,7 @@ Options:
   --warmup <seconds>
   --external-equivalence
   --allocation-iterations <count>
+  --baseline-worktree <dir>
 ```
 
 Examples:
@@ -55,9 +56,9 @@ Examples:
 
 ## External-Type Equivalence
 
-The opt-in external-type mode compares generated serializers for ordinary
-benchmark models with external structural serializers for equivalent models
-from a Fory-free project:
+The opt-in external-type mode compares the latest `apache/main` ordinary
+serializer, the current ordinary serializer, and the current external
+structural serializer for equivalent models from a Fory-free project:
 
 ```bash
 ./run.sh --external-equivalence --duration 10 --warmup 3
@@ -74,13 +75,22 @@ package that owns the external-type models and measurements. Building or
 running the ordinary benchmark therefore does not compile external-type models
 or their generated serializer instantiations.
 
-The runner builds the external-type benchmark once. For each selected lane, it
-launches two balanced adjacent pairs in fixed order: ordinary then external,
-followed by external then ordinary. Each of the four workers runs in a fresh
-process and constructs, registers, validates, warms, times, and optionally
-measures allocations for only its selected implementation. Keeping the
+The runner fetches `apache/main` and reuses the dedicated sibling worktree
+`../fory-benchmark-baseline` by default. Use `--baseline-worktree` to select
+another dedicated path. If the worktree already exists, the runner refuses
+tracked changes before switching it to the fetched commit. It never resets or
+cleans the worktree, so untracked files are preserved and a conflicting file
+causes the switch to fail safely.
+
+The runner builds the baseline and current external-type benchmark projects
+separately. For each selected lane, it launches two balanced adjacent triples
+in fixed order: `apache-main ordinary external`, followed by
+`external ordinary apache-main`. Each of the six workers runs in a fresh
+process and constructs, registers, validates, warms, times, and measures
+allocations for only its selected implementation. Each baseline/current and
+ordinary/external comparison is therefore adjacent in both orders. Keeping the
 implementations in separate processes prevents shared reference-generic
-Dynamic PGO profiles from biasing the second implementation while retaining
+Dynamic PGO profiles from biasing a later implementation while retaining
 normal .NET tiering.
 
 Use `--data` to select one of these lanes:
@@ -93,7 +103,8 @@ Use `--data` to select one of these lanes:
 - `map-field`
 - `map-root`
 
-Add a fixed allocation pass with:
+Every worker runs a fixed allocation pass with 100,000 operations by default.
+To select another fixed count, use:
 
 ```bash
 ./run.sh --external-equivalence --allocation-iterations 100000
@@ -107,14 +118,17 @@ allocation counters start. Allocation results use
 Results are written to
 `build/external_equivalence_results.json`. Per-process results, including a
 Base64 proof frame, are written under `build/external_equivalence_sides/` with
-the lane, one-based sample index, and implementation in each filename. After
-every worker finishes, the merger rejects missing, duplicate, reordered, or
-reused samples, incompatible runtime metadata, serialized size or byte
-differences, and allocation differences. It reports the median ordinary and
-external throughput and latency from the two samples per implementation. The
-combined JSON records the sample count and fixed pair orders. This opt-in mode
-does not invoke the default Python report generator or update the published
-36-case report.
+the lane, one-based sample index, and role in each filename. After every worker
+finishes, the merger rejects missing, duplicate, reordered, or reused samples,
+incompatible runtime metadata, serialized size or byte differences, and
+unstable per-role allocation samples. It fails if current ordinary latency is
+more than 1% above `apache/main`, current external latency is more than 1% above
+current ordinary, current external latency is more than 1% above `apache/main`,
+current ordinary allocates more than `apache/main`, or current external
+allocation differs from current ordinary. The complete JSON, including commit
+provenance and every violation, is written before a regression returns a
+nonzero exit code. This opt-in mode does not invoke the default Python report
+generator or update the published 36-case report.
 
 The benchmark executable requires exactly one implementation for direct worker
 use:
@@ -126,6 +140,7 @@ dotnet run -c Release --no-build \
   --data class-root \
   --duration 10 \
   --warmup 3 \
+  --allocation-iterations 100000 \
   --output build/external_equivalence_sides/class-root-direct-ordinary.json
 ```
 
diff --git a/benchmarks/csharp/external_equivalence_report.py b/benchmarks/csharp/external_equivalence_report.py
index 95a9944d4..07864c5fc 100755
--- a/benchmarks/csharp/external_equivalence_report.py
+++ b/benchmarks/csharp/external_equivalence_report.py
@@ -21,6 +21,7 @@ import base64
 import binascii
 import json
 import math
+import re
 import sys
 from datetime import datetime, timezone
 from pathlib import Path
@@ -36,11 +37,17 @@ LANES = (
     "map-root",
 )
 OPERATIONS = ("serialize", "deserialize")
-IMPLEMENTATIONS = ("ordinary", "external")
+ROLES = ("apache-main", "ordinary", "external")
+WORKER_IMPLEMENTATIONS = {
+    "apache-main": "ordinary",
+    "ordinary": "ordinary",
+    "external": "external",
+}
 SAMPLE_ORDERS = (
-    ("ordinary", "external"),
-    ("external", "ordinary"),
+    ("apache-main", "ordinary", "external"),
+    ("external", "ordinary", "apache-main"),
 )
+MAX_SLOWDOWN_PERCENT = 1.0
 TEXT_METADATA = (
     "RuntimeVersion",
     "OsDescription",
@@ -68,14 +75,37 @@ def parse_args(argv=None):
     parser.add_argument(
         "--sample",
         action="append",
-        nargs=5,
+        nargs=6,
         required=True,
-        metavar=("LANE", "INDEX", "ORDER", "FIRST_INPUT", "SECOND_INPUT"),
+        metavar=(
+            "LANE",
+            "INDEX",
+            "ORDER",
+            "FIRST_INPUT",
+            "SECOND_INPUT",
+            "THIRD_INPUT",
+        ),
         help=(
-            "Balanced sample metadata and worker JSON paths; repeat for each lane "
-            "and sample."
+            "Balanced apache-main/current-ordinary/current-external sample metadata "
+            "and worker JSON paths; repeat for each lane and sample."
         ),
     )
+    parser.add_argument(
+        "--apache-main-commit",
+        required=True,
+        help="Fetched apache/main commit used by baseline workers.",
+    )
+    parser.add_argument(
+        "--current-commit",
+        required=True,
+        help="Current worktree HEAD commit used by ordinary/external workers.",
+    )
+    parser.add_argument(
+        "--current-dirty",
+        required=True,
+        choices=("true", "false"),
+        help="Whether the current worktree had changes when workers were launched.",
+    )
     parser.add_argument("--output", required=True, help="Combined JSON output path.")
     return parser.parse_args(argv)
 
@@ -110,6 +140,13 @@ def require_number(value, owner: str, positive: bool):
     return value
 
 
+def require_commit(value, owner: str) -> str:
+    commit = require_string(value, owner)
+    if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
+        raise ValueError(f"{owner} must be a lowercase 40-character commit id")
+    return commit
+
+
 def validate_metadata(document: dict, path: Path) -> dict:
     owner = str(path)
     require_string(document.get("GeneratedAtUtc"), f"{owner}.GeneratedAtUtc")
@@ -121,7 +158,7 @@ def validate_metadata(document: dict, path: Path) -> dict:
     require_int(
         document.get("AllocationIterations"),
         f"{owner}.AllocationIterations",
-        0,
+        1,
     )
     return {field: document[field] for field in METADATA_FIELDS}
 
@@ -164,7 +201,14 @@ def parse_sample_specs(raw_samples, expected_lanes):
     input_owners = {}
 
     for raw_sample in raw_samples:
-        lane, raw_index, order_name, first_input, second_input = raw_sample
+        (
+            lane,
+            raw_index,
+            order_name,
+            first_input,
+            second_input,
+            third_input,
+        ) = raw_sample
         if lane not in expected_lane_set:
             raise ValueError(f"sample lane is not expected: {lane!r}")
         try:
@@ -202,6 +246,12 @@ def parse_sample_specs(raw_samples, expected_lanes):
                     f"sample {lane}/{sample_index} second input",
                 )
             ),
+            Path(
+                require_string(
+                    third_input,
+                    f"sample {lane}/{sample_index} third input",
+                )
+            ),
         )
         for path in paths:
             normalized = path.resolve()
@@ -271,23 +321,13 @@ def load_worker(path: Path, expected_lane: str, expected_implementation: str):
             result.get("Measurement"), f"{owner}.Measurement"
         )
         allocated = result.get("AllocatedBytesPerOperation")
-        if metadata["AllocationIterations"] == 0:
-            if allocated is not None:
-                raise ValueError(
-                    f"{owner}.AllocatedBytesPerOperation must be null when "
-                    "allocation measurement is disabled"
-                )
-        elif allocated is None:
-            raise ValueError(
-                f"{owner}.AllocatedBytesPerOperation is required when "
-                "allocation measurement is enabled"
-            )
-        else:
-            require_number(
-                allocated,
-                f"{owner}.AllocatedBytesPerOperation",
-                False,
-            )
+        if allocated is None:
+            raise ValueError(f"{owner}.AllocatedBytesPerOperation is required")
+        require_number(
+            allocated,
+            f"{owner}.AllocatedBytesPerOperation",
+            False,
+        )
 
         results[operation] = {
             "SerializedSize": serialized_size,
@@ -324,11 +364,11 @@ def load_samples(specs, expected_lanes):
                 raise ValueError(
                     f"internal sample order mismatch for {lane}/{sample_index}"
                 )
-            for implementation, path in zip(expected_order, spec["Paths"]):
+            for role, path in zip(expected_order, spec["Paths"]):
                 current_metadata, worker_results = load_worker(
                     path,
                     lane,
-                    implementation,
+                    WORKER_IMPLEMENTATIONS[role],
                 )
                 if metadata is None:
                     metadata = current_metadata
@@ -344,7 +384,7 @@ def load_samples(specs, expected_lanes):
                     )
 
                 for operation, result in worker_results.items():
-                    results[(implementation, lane, sample_index, operation)] = result
+                    results[(role, lane, sample_index, operation)] = result
 
     if metadata is None:
         raise ValueError("no benchmark samples were loaded")
@@ -362,10 +402,30 @@ def median_summary(samples) -> dict:
     }
 
 
-def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
+def stable_allocation(samples, owner: str):
+    allocations = {sample["AllocatedBytesPerOperation"] for sample in samples}
+    if len(allocations) != 1:
+        raise ValueError(f"allocation mismatch between samples for {owner}")
+    return next(iter(allocations))
+
+
+def slowdown_percent(candidate: dict, baseline: dict) -> float:
+    return (
+        candidate["AverageNanoseconds"] / baseline["AverageNanoseconds"] - 1.0
+    ) * 100.0
+
+
+def merge_results(
+    metadata: dict,
+    results: dict,
+    expected_lanes,
+    apache_main_commit: str,
+    current_commit: str,
+    current_dirty: bool,
+) -> dict:
     expected_keys = {
-        (implementation, lane, sample_index, operation)
-        for implementation in IMPLEMENTATIONS
+        (role, lane, sample_index, operation)
+        for role in ROLES
         for lane in expected_lanes
         for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
         for operation in OPERATIONS
@@ -375,8 +435,8 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
         raise ValueError(
             "missing results: "
             + ", ".join(
-                f"{implementation}/{lane}/{sample_index}/{operation}"
-                for implementation, lane, sample_index, operation in sorted(missing)
+                f"{role}/{lane}/{sample_index}/{operation}"
+                for role, lane, sample_index, operation in sorted(missing)
             )
         )
     unexpected = set(results) - expected_keys
@@ -384,28 +444,33 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
         raise ValueError(
             "unexpected results: "
             + ", ".join(
-                f"{implementation}/{lane}/{sample_index}/{operation}"
-                for implementation, lane, sample_index, operation in sorted(unexpected)
+                f"{role}/{lane}/{sample_index}/{operation}"
+                for role, lane, sample_index, operation in sorted(unexpected)
             )
         )
 
     combined_results = []
+    violations = []
     for lane in expected_lanes:
-        baseline = results[("ordinary", lane, 1, "serialize")]
+        proof = results[("apache-main", lane, 1, "serialize")]
         lane_samples = [
-            results[(implementation, lane, sample_index, operation)]
-            for implementation in IMPLEMENTATIONS
+            results[(role, lane, sample_index, operation)]
+            for role in ROLES
             for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
             for operation in OPERATIONS
         ]
         for sample in lane_samples:
             if (
-                sample["SerializedSize"] != baseline["SerializedSize"]
-                or sample["Frame"] != baseline["Frame"]
+                sample["SerializedSize"] != proof["SerializedSize"]
+                or sample["Frame"] != proof["Frame"]
             ):
                 raise ValueError(f"serialized frame differs between samples for {lane}")
 
         for operation in OPERATIONS:
+            apache_main_samples = [
+                results[("apache-main", lane, sample_index, operation)]
+                for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
+            ]
             ordinary_samples = [
                 results[("ordinary", lane, sample_index, operation)]
                 for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
@@ -414,43 +479,103 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
                 results[("external", lane, sample_index, operation)]
                 for sample_index in range(1, len(SAMPLE_ORDERS) + 1)
             ]
-            operation_samples = ordinary_samples + external_samples
-            allocations = {
-                sample["AllocatedBytesPerOperation"] for sample in operation_samples
-            }
-            if len(allocations) != 1:
-                raise ValueError(
-                    f"allocation mismatch between samples for {lane}/{operation}"
-                )
 
-            allocated_bytes = operation_samples[0]["AllocatedBytesPerOperation"]
+            apache_main_allocation = stable_allocation(
+                apache_main_samples,
+                f"apache-main/{lane}/{operation}",
+            )
+            ordinary_allocation = stable_allocation(
+                ordinary_samples,
+                f"ordinary/{lane}/{operation}",
+            )
+            external_allocation = stable_allocation(
+                external_samples,
+                f"external/{lane}/{operation}",
+            )
+
+            apache_main_median = median_summary(apache_main_samples)
             ordinary_median = median_summary(ordinary_samples)
             external_median = median_summary(external_samples)
-            slowdown = (
-                external_median["AverageNanoseconds"]
-                / ordinary_median["AverageNanoseconds"]
-                - 1.0
-            ) * 100.0
-            allocation_enabled = metadata["AllocationIterations"] > 0
+            ordinary_slowdown = slowdown_percent(
+                ordinary_median,
+                apache_main_median,
+            )
+            external_slowdown = slowdown_percent(
+                external_median,
+                ordinary_median,
+            )
+            external_vs_apache_main_slowdown = slowdown_percent(
+                external_median,
+                apache_main_median,
+            )
+
+            if ordinary_slowdown > MAX_SLOWDOWN_PERCENT:
+                violations.append(
+                    f"{lane}/{operation}: current ordinary is "
+                    f"{ordinary_slowdown:+.2f}% slower than apache/main "
+                    f"(limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
+                )
+            if external_slowdown > MAX_SLOWDOWN_PERCENT:
+                violations.append(
+                    f"{lane}/{operation}: current external is "
+                    f"{external_slowdown:+.2f}% slower than current ordinary "
+                    f"(limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
+                )
+            if external_vs_apache_main_slowdown > MAX_SLOWDOWN_PERCENT:
+                violations.append(
+                    f"{lane}/{operation}: current external is "
+                    f"{external_vs_apache_main_slowdown:+.2f}% slower than "
+                    f"apache/main (limit: +{MAX_SLOWDOWN_PERCENT:.2f}%)"
+                )
+            if ordinary_allocation > apache_main_allocation:
+                violations.append(
+                    f"{lane}/{operation}: current ordinary allocates "
+                    f"{ordinary_allocation:.2f} B/op, above apache/main "
+                    f"{apache_main_allocation:.2f} B/op"
+                )
+            if external_allocation != ordinary_allocation:
+                violations.append(
+                    f"{lane}/{operation}: current external allocation "
+                    f"{external_allocation:.2f} B/op differs from current ordinary "
+                    f"{ordinary_allocation:.2f} B/op"
+                )
+
             combined_results.append(
                 {
                     "DataType": lane,
                     "Operation": operation,
-                    "SerializedSize": baseline["SerializedSize"],
-                    "OrdinaryMedian": ordinary_median,
-                    "ExternalMedian": external_median,
-                    "SlowdownPercent": slowdown,
-                    "OrdinaryAllocatedBytesPerOperation": allocated_bytes,
-                    "ExternalAllocatedBytesPerOperation": allocated_bytes,
-                    "AllocationEqual": True if allocation_enabled else None,
+                    "SerializedSize": proof["SerializedSize"],
+                    "ApacheMainMedian": apache_main_median,
+                    "CurrentOrdinaryMedian": ordinary_median,
+                    "CurrentExternalMedian": external_median,
+                    "CurrentOrdinarySlowdownPercent": ordinary_slowdown,
+                    "CurrentExternalSlowdownPercent": external_slowdown,
+                    "ExternalVsApacheMainSlowdownPercent": (
+                        external_vs_apache_main_slowdown
+                    ),
+                    "ApacheMainAllocatedBytesPerOperation": apache_main_allocation,
+                    "CurrentOrdinaryAllocatedBytesPerOperation": ordinary_allocation,
+                    "CurrentExternalAllocatedBytesPerOperation": external_allocation,
+                    "CurrentOrdinaryAllocationWithinBaseline": (
+                        ordinary_allocation <= apache_main_allocation
+                    ),
+                    "CurrentExternalAllocationEqual": (
+                        external_allocation == ordinary_allocation
+                    ),
                 }
             )
 
     return {
         "GeneratedAtUtc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
+        "ApacheMainCommit": apache_main_commit,
+        "CurrentCommit": current_commit,
+        "CurrentDirty": current_dirty,
         **metadata,
-        "SamplesPerImplementation": len(SAMPLE_ORDERS),
+        "MaximumSlowdownPercent": MAX_SLOWDOWN_PERCENT,
+        "SamplesPerRole": len(SAMPLE_ORDERS),
         "SampleOrders": [sample_order_name(order) for order in SAMPLE_ORDERS],
+        "Passed": not violations,
+        "Violations": violations,
         "Results": combined_results,
     }
 
@@ -458,16 +583,19 @@ def merge_results(metadata: dict, results: dict, expected_lanes) -> dict:
 def print_summary(output: dict) -> None:
     print("=== External-Type Equivalence Median Summary ===")
     for result in output["Results"]:
-        allocation = ""
-        if result["AllocationEqual"] is not None:
-            allocation = (
-                f", allocated={result['OrdinaryAllocatedBytesPerOperation']:.2f} B/op"
-            )
         print(
             f"{result['DataType']}/{result['Operation']}: "
-            f"ordinary={result['OrdinaryMedian']['AverageNanoseconds']:.1f} ns/op, "
-            f"external={result['ExternalMedian']['AverageNanoseconds']:.1f} ns/op, "
-            f"slowdown={result['SlowdownPercent']:+.2f}%{allocation}"
+            f"apache-main={result['ApacheMainMedian']['AverageNanoseconds']:.1f} ns/op, "
+            f"ordinary={result['CurrentOrdinaryMedian']['AverageNanoseconds']:.1f} "
+            f"ns/op ({result['CurrentOrdinarySlowdownPercent']:+.2f}%), "
+            f"external={result['CurrentExternalMedian']['AverageNanoseconds']:.1f} "
+            f"ns/op (vs ordinary "
+            f"{result['CurrentExternalSlowdownPercent']:+.2f}%, vs apache-main "
+            f"{result['ExternalVsApacheMainSlowdownPercent']:+.2f}%), "
+            f"allocated="
+            f"{result['ApacheMainAllocatedBytesPerOperation']:.2f}/"
+            f"{result['CurrentOrdinaryAllocatedBytesPerOperation']:.2f}/"
+            f"{result['CurrentExternalAllocatedBytesPerOperation']:.2f} B/op"
         )
 
 
@@ -479,13 +607,24 @@ def main(argv=None) -> int:
 
     specs = parse_sample_specs(args.sample, lanes)
     metadata, results = load_samples(specs, lanes)
-    output = merge_results(metadata, results, lanes)
+    output = merge_results(
+        metadata,
+        results,
+        lanes,
+        require_commit(args.apache_main_commit, "--apache-main-commit"),
+        require_commit(args.current_commit, "--current-commit"),
+        args.current_dirty == "true",
+    )
     output_path = Path(args.output)
     output_path.parent.mkdir(parents=True, exist_ok=True)
     with output_path.open("w", encoding="utf-8") as destination:
         json.dump(output, destination, indent=2)
         destination.write("\n")
     print_summary(output)
+    if not output["Passed"]:
+        for violation in output["Violations"]:
+            print(f"regression: {violation}", file=sys.stderr)
+        return 1
     return 0
 
 
diff --git a/benchmarks/csharp/run.sh b/benchmarks/csharp/run.sh
index 4c3f47fc2..bc953d8fe 100755
--- a/benchmarks/csharp/run.sh
+++ b/benchmarks/csharp/run.sh
@@ -36,6 +36,7 @@ OUTPUT_DIR=""
 COPY_DOCS=true
 EXTERNAL_EQUIVALENCE=false
 ALLOCATION_ITERATIONS=""
+BASELINE_WORKTREE=""
 
 usage() {
     cat <<USAGE
@@ -53,7 +54,8 @@ Options:
   --output-dir <dir>           Base directory for benchmark outputs
   --no-copy-docs               Skip copying report/plots into docs/benchmarks/csharp
   --external-equivalence       Run ordinary/external Fory equivalence cases
-  --allocation-iterations <n>  Measure allocations for each equivalence case
+  --allocation-iterations <n>  Allocation operations per equivalence case (default: 100000)
+  --baseline-worktree <dir>    Dedicated apache/main worktree for equivalence runs
   --help                       Show this help
 USAGE
     exit 0
@@ -93,6 +95,10 @@ while [[ $# -gt 0 ]]; do
             ALLOCATION_ITERATIONS="$2"
             shift 2
             ;;
+        --baseline-worktree)
+            BASELINE_WORKTREE="$2"
+            shift 2
+            ;;
         --help|-h)
             usage
             ;;
@@ -113,6 +119,11 @@ if [[ "$EXTERNAL_EQUIVALENCE" == false && -n "$ALLOCATION_ITERATIONS" ]]; then
     exit 1
 fi
 
+if [[ "$EXTERNAL_EQUIVALENCE" == false && -n "$BASELINE_WORKTREE" ]]; then
+    echo -e "${RED}--baseline-worktree requires --external-equivalence.${NC}"
+    exit 1
+fi
+
 if [[ "$EXTERNAL_EQUIVALENCE" == true && "${FORY_BENCH_SCHEMA_MISMATCH:-0}" == "1" ]]; then
     echo -e "${RED}--external-equivalence does not support schema-mismatch mode.${NC}"
     exit 1
@@ -150,6 +161,11 @@ if [[ "$EXTERNAL_EQUIVALENCE" == true ]]; then
     RESULT_JSON="$BUILD_DIR/external_equivalence_results.json"
     SIDE_DIR="$BUILD_DIR/external_equivalence_sides"
     mkdir -p "$SIDE_DIR"
+    ALLOCATION_ITERATIONS="${ALLOCATION_ITERATIONS:-100000}"
+    if [[ ! "$ALLOCATION_ITERATIONS" =~ ^[1-9][0-9]*$ ]]; then
+        echo -e "${RED}--allocation-iterations must be a positive integer.${NC}"
+        exit 1
+    fi
 
     if [[ -n "$DATA" ]]; then
         EXTERNAL_LANES=("$DATA")
@@ -165,56 +181,138 @@ if [[ "$EXTERNAL_EQUIVALENCE" == true ]]; then
         )
     fi
 
-    echo -e "${YELLOW}[1/4] Restoring dependencies...${NC}"
-    dotnet restore ./Fory.ExternalTypeBenchmark.csproj >/dev/null
+    REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
+    if [[ -z "$BASELINE_WORKTREE" ]]; then
+        BASELINE_WORKTREE="$(dirname "$REPO_ROOT")/fory-benchmark-baseline"
+    fi
 
-    echo -e "${YELLOW}[2/4] Building benchmark once...${NC}"
-    dotnet build -c Release --no-restore ./Fory.ExternalTypeBenchmark.csproj
+    echo -e "${YELLOW}[1/5] Preparing fresh apache/main baseline...${NC}"
+    git -C "$REPO_ROOT" fetch apache main
+    APACHE_MAIN_COMMIT="$(
+        git -C "$REPO_ROOT" rev-parse --verify FETCH_HEAD^{commit}
+    )"
+
+    if [[ -e "$BASELINE_WORKTREE" ]]; then
+        BASELINE_REPO_ROOT="$(
+            git -C "$BASELINE_WORKTREE" rev-parse --show-toplevel 2>/dev/null
+        )" || {
+            echo -e "${RED}Baseline path is not a git worktree: $BASELINE_WORKTREE${NC}"
+            exit 1
+        }
+        CURRENT_COMMON_DIR="$(
+            git -C "$REPO_ROOT" rev-parse --path-format=absolute --git-common-dir
+        )"
+        BASELINE_COMMON_DIR="$(
+            git -C "$BASELINE_WORKTREE" \
+                rev-parse --path-format=absolute --git-common-dir
+        )"
+        if [[ "$BASELINE_COMMON_DIR" != "$CURRENT_COMMON_DIR" ]]; then
+            echo -e "${RED}Baseline path is not a worktree of the current repository.${NC}"
+            exit 1
+        fi
+        if [[ "$BASELINE_REPO_ROOT" == "$REPO_ROOT" ]]; then
+            echo -e "${RED}Baseline worktree must differ from the current worktree.${NC}"
+            exit 1
+        fi
+        if ! git -C "$BASELINE_WORKTREE" diff --quiet -- ||
+            ! git -C "$BASELINE_WORKTREE" diff --cached --quiet --; then
+            echo -e "${RED}Baseline worktree has tracked changes: $BASELINE_WORKTREE${NC}"
+            exit 1
+        fi
+        if ! git -C "$BASELINE_WORKTREE" switch \
+            --detach --no-overwrite-ignore "$APACHE_MAIN_COMMIT"; then
+            echo -e "${RED}Could not switch the baseline worktree safely; its files were not reset or cleaned.${NC}"
+            exit 1
+        fi
+    else
+        git -C "$REPO_ROOT" worktree add \
+            --detach "$BASELINE_WORKTREE" "$APACHE_MAIN_COMMIT"
+    fi
 
-    echo -e "${YELLOW}[3/4] Running isolated adjacent pairs...${NC}"
-    MERGE_ARGS=(--output "$RESULT_JSON")
+    BASELINE_COMMIT="$(git -C "$BASELINE_WORKTREE" rev-parse HEAD)"
+    if [[ "$BASELINE_COMMIT" != "$APACHE_MAIN_COMMIT" ]]; then
+        echo -e "${RED}Baseline worktree is not at the fetched apache/main commit.${NC}"
+        exit 1
+    fi
+
+    CURRENT_COMMIT="$(git -C "$REPO_ROOT" rev-parse HEAD)"
+    if [[ -n "$(git -C "$REPO_ROOT" status --porcelain --untracked-files=normal)" ]]; then
+        CURRENT_DIRTY=true
+    else
+        CURRENT_DIRTY=false
+    fi
+
+    CURRENT_PROJECT="$SCRIPT_DIR/Fory.ExternalTypeBenchmark.csproj"
+    BASELINE_PROJECT="$BASELINE_WORKTREE/benchmarks/csharp/Fory.ExternalTypeBenchmark.csproj"
+
+    echo -e "${YELLOW}[2/5] Restoring baseline and current dependencies...${NC}"
+    dotnet restore "$BASELINE_PROJECT" >/dev/null
+    dotnet restore "$CURRENT_PROJECT" >/dev/null
+
+    echo -e "${YELLOW}[3/5] Building baseline and current benchmarks...${NC}"
+    dotnet build -c Release --no-restore "$BASELINE_PROJECT"
+    dotnet build -c Release --no-restore "$CURRENT_PROJECT"
+
+    echo -e "${YELLOW}[4/5] Running isolated adjacent triples...${NC}"
+    MERGE_ARGS=(
+        --output "$RESULT_JSON"
+        --apache-main-commit "$APACHE_MAIN_COMMIT"
+        --current-commit "$CURRENT_COMMIT"
+        --current-dirty "$CURRENT_DIRTY"
+    )
     for LANE in "${EXTERNAL_LANES[@]}"; do
         MERGE_ARGS+=(--lane "$LANE")
         WORKER_ARGS=(
             --data "$LANE"
             --duration "$DURATION"
             --warmup "$WARMUP"
+            --allocation-iterations "$ALLOCATION_ITERATIONS"
         )
-        if [[ -n "$ALLOCATION_ITERATIONS" ]]; then
-            WORKER_ARGS+=(--allocation-iterations "$ALLOCATION_ITERATIONS")
-        fi
 
         for SAMPLE_INDEX in 1 2; do
             if [[ "$SAMPLE_INDEX" == 1 ]]; then
-                PAIR_ORDER=(ordinary external)
+                SAMPLE_ORDER=(apache-main ordinary external)
             else
-                PAIR_ORDER=(external ordinary)
+                SAMPLE_ORDER=(external ordinary apache-main)
             fi
 
             SIDE_INPUTS=()
-            for IMPLEMENTATION in "${PAIR_ORDER[@]}"; do
-                SIDE_JSON="$SIDE_DIR/${LANE}-sample-${SAMPLE_INDEX}-${IMPLEMENTATION}.json"
+            for ROLE in "${SAMPLE_ORDER[@]}"; do
+                if [[ "$ROLE" == apache-main ]]; then
+                    PROJECT="$BASELINE_PROJECT"
+                    IMPLEMENTATION=ordinary
+                else
+                    PROJECT="$CURRENT_PROJECT"
+                    IMPLEMENTATION="$ROLE"
+                fi
+
+                SIDE_JSON="$SIDE_DIR/${LANE}-sample-${SAMPLE_INDEX}-${ROLE}.json"
                 SIDE_INPUTS+=("$SIDE_JSON")
-                echo "Running $LANE sample $SAMPLE_INDEX ${PAIR_ORDER[*]}: $IMPLEMENTATION worker..."
+                echo "Running $LANE sample $SAMPLE_INDEX ${SAMPLE_ORDER[*]}: $ROLE worker..."
                 dotnet run -c Release --no-build \
-                    --project ./Fory.ExternalTypeBenchmark.csproj -- \
+                    --project "$PROJECT" -- \
                     "${WORKER_ARGS[@]}" \
                     --external-implementation "$IMPLEMENTATION" \
                     --output "$SIDE_JSON"
             done
 
+            SAMPLE_ORDER_NAME="$(
+                IFS=-
+                echo "${SAMPLE_ORDER[*]}"
+            )"
             MERGE_ARGS+=(
                 --sample
                 "$LANE"
                 "$SAMPLE_INDEX"
-                "${PAIR_ORDER[0]}-${PAIR_ORDER[1]}"
+                "$SAMPLE_ORDER_NAME"
                 "${SIDE_INPUTS[0]}"
                 "${SIDE_INPUTS[1]}"
+                "${SIDE_INPUTS[2]}"
             )
         done
     done
 
-    echo -e "${YELLOW}[4/4] Validating and merging results...${NC}"
+    echo -e "${YELLOW}[5/5] Validating and merging results...${NC}"
     python3 external_equivalence_report.py "${MERGE_ARGS[@]}"
     echo ""
     echo -e "${GREEN}=== All done! ===${NC}"
diff --git a/csharp/README.md b/csharp/README.md
index a505ea727..af4d5b99d 100644
--- a/csharp/README.md
+++ b/csharp/README.md
@@ -199,6 +199,7 @@ internal abstract class UserSerializer
 Fory fory = Fory.Builder().Build();
 fory.Register<ThirdParty.User>(300);
 
+ThirdParty.User user = new() { Name = "Alice" };
 byte[] payload = fory.Serialize(user);
 ThirdParty.User decoded = fory.Deserialize<ThirdParty.User>(payload);
 ```
diff --git a/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md b/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
index 9f5047f7b..42a1240c9 100644
--- a/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
+++ b/csharp/src/Fory.Generator/AnalyzerReleases.Unshipped.md
@@ -16,3 +16,4 @@ FORY011 | Fory | Error | Invalid external serializer member
 FORY012 | Fory | Error | External serializer member type mismatch
 FORY013 | Fory | Error | Unsupported extern alias
 FORY014 | Fory | Error | Enum value is outside the supported range
+FORY015 | Fory | Error | Invalid ignored Fory field
diff --git a/csharp/src/Fory.Generator/ForyModelGenerator.cs b/csharp/src/Fory.Generator/ForyModelGenerator.cs
index 76ad404ec..98409897a 100644
--- a/csharp/src/Fory.Generator/ForyModelGenerator.cs
+++ b/csharp/src/Fory.Generator/ForyModelGenerator.cs
@@ -146,6 +146,14 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         defaultSeverity: DiagnosticSeverity.Error,
         isEnabledByDefault: true);
 
+    private static readonly DiagnosticDescriptor InvalidIgnoredField = new(
+        id: "FORY015",
+        title: "Invalid ignored Fory field",
+        messageFormat: "Member '{0}' uses invalid [ForyField(Ignore = true)]: {1}",
+        category: "Fory",
+        defaultSeverity: DiagnosticSeverity.Error,
+        isEnabledByDefault: true);
+
     public void Initialize(IncrementalGeneratorInitializationContext context)
     {
         IncrementalValuesProvider<TypeModel?> typeModels = context.SyntaxProvider
@@ -206,10 +214,11 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             validModels.Add(model);
         }
 
-        Dictionary<string, TypeModel> models = new(StringComparer.Ordinal);
-        foreach (IGrouping<string, TypeModel> targetGroup in validModels.GroupBy(
-                     model => model.TargetTypeName,
-                     StringComparer.Ordinal))
+        IEqualityComparer<ITypeSymbol> targetTypeComparer = RuntimeTypeComparer.Instance;
+        Dictionary<ITypeSymbol, TypeModel> models = new(targetTypeComparer);
+        foreach (IGrouping<ITypeSymbol, TypeModel> targetGroup in validModels.GroupBy(
+                     model => model.TargetType,
+                     targetTypeComparer))
         {
             if (targetGroup.Skip(1).Any())
             {
@@ -225,7 +234,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             }
 
             TypeModel targetModel = targetGroup.First();
-            models.Add(targetModel.TargetTypeName, targetModel);
+            models.Add(targetModel.TargetType, targetModel);
         }
 
         if (models.Count == 0)
@@ -244,9 +253,10 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         sb.AppendLine("}");
         sb.AppendLine();
 
-        foreach (KeyValuePair<string, TypeModel> entry in models.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+        foreach (TypeModel model in models.Values
+                     .OrderBy(model => model.TargetTypeName, StringComparer.Ordinal)
+                     .ThenBy(model => model.DeclarationName, StringComparer.Ordinal))
         {
-            TypeModel model = entry.Value;
             if (model.Kind == DeclKind.Struct || model.Kind == DeclKind.Class)
             {
                 EmitObjectSerializer(sb, model);
@@ -264,9 +274,10 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         sb.AppendLine("    [global::System.Runtime.CompilerServices.ModuleInitializer]");
         sb.AppendLine("    internal static void Register()");
         sb.AppendLine("    {");
-        foreach (KeyValuePair<string, TypeModel> entry in models.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+        foreach (TypeModel model in models.Values
+                     .OrderBy(model => model.TargetTypeName, StringComparer.Ordinal)
+                     .ThenBy(model => model.DeclarationName, StringComparer.Ordinal))
         {
-            TypeModel model = entry.Value;
             if (model.Kind == DeclKind.Enum)
             {
                 sb.AppendLine(
@@ -2104,9 +2115,9 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
     private static string ModelGraphMemoryExpr(TypeModel model)
     {
         System.Collections.Generic.List<string> parts = new() { GraphObjectOwnerBytesExpr };
-        foreach (MemberModel member in model.SortedMembers)
+        foreach (GraphFieldModel field in model.GraphFields)
         {
-            parts.Add(FieldGraphMemoryExpr(member));
+            parts.Add(field.MemoryExpression);
         }
 
         return string.Join(" + ", parts);
@@ -2114,6 +2125,11 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
 
     private static string FieldGraphMemoryExpr(MemberModel member)
     {
+        if (member.IsNullableValueType)
+        {
+            return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{member.TypeName}>()";
+        }
+
         if (member.Classification.IsPrimitive && member.Classification.PrimitiveSize > 0)
         {
             return $"{member.Classification.PrimitiveSize}";
@@ -2129,8 +2145,45 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return $"{member.FixedValueBytes}";
         }
 
-        string typeName = StripNullableForTypeOf(member.TypeName);
-        return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{typeName}>()";
+        return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{member.TypeName}>()";
+    }
+
+    private static string FieldGraphMemoryExpr(
+        ITypeSymbol fieldType,
+        IFieldSymbol? targetField = null)
+    {
+        if (targetField is { IsFixedSizeBuffer: true })
+        {
+            ITypeSymbol elementType = targetField.Type is IPointerTypeSymbol pointer
+                ? pointer.PointedAtType
+                : targetField.Type;
+            return $"({targetField.FixedSize} * {FieldGraphMemoryExpr(elementType)})";
+        }
+
+        if (fieldType.TypeKind is TypeKind.Pointer or TypeKind.FunctionPointer)
+        {
+            return "global::System.IntPtr.Size";
+        }
+
+        if (!fieldType.IsValueType)
+        {
+            return "4";
+        }
+
+        if (fieldType is INamedTypeSymbol nullableType &&
+            nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
+        {
+            return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{fieldType.ToDisplayString(FullNameFormat)}>()";
+        }
+
+        TypeClassification classification = ClassifyType(fieldType);
+        int fixedValueBytes = FixedGraphValueBytes(fieldType, classification);
+        if (fixedValueBytes > 0)
+        {
+            return fixedValueBytes.ToString(CultureInfo.InvariantCulture);
+        }
+
+        return $"global::System.Runtime.CompilerServices.Unsafe.SizeOf<{fieldType.ToDisplayString(FullNameFormat)}>()";
     }
 
     private static bool IsConstGraphMemoryExpr(string expression)
@@ -2944,6 +2997,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 DeclKind.Unknown,
                 evolving,
@@ -2985,6 +3039,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 DeclKind.Unknown,
                 evolving,
@@ -3007,6 +3062,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
                 return new TypeModel(
                     declarationName,
                     declarationName,
+                    typeSymbol,
                     serializerName,
                     DeclKind.Unknown,
                     evolving,
@@ -3052,6 +3108,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             _ => new TypeModel(
                 declarationName,
                 declarationName,
+                target,
                 serializerName,
                 DeclKind.Unknown,
                 evolving,
@@ -3075,11 +3132,37 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         bool evolving,
         Location? declarationLocation)
     {
+        ImmutableArray<Diagnostic> ignoredFieldDiagnostics = typeSymbol.GetMembers()
+            .Where(member =>
+                member is IFieldSymbol or IPropertySymbol &&
+                TryGetIgnoredField(member, out _))
+            .Select(member => Diagnostic.Create(
+                InvalidIgnoredField,
+                member.Locations.FirstOrDefault(location => location.IsInSource),
+                member.Name,
+                "Ignore is supported only by external ForyStruct serializer declarations"))
+            .ToImmutableArray();
+        if (!ignoredFieldDiagnostics.IsEmpty)
+        {
+            return new TypeModel(
+                declarationName,
+                declarationName,
+                typeSymbol,
+                serializerName,
+                DeclKind.Unknown,
+                evolving,
+                declarationLocation,
+                ImmutableArray<MemberModel>.Empty,
+                ImmutableArray<MemberModel>.Empty,
+                ignoredFieldDiagnostics);
+        }
+
         if (HasGenericContext(typeSymbol))
         {
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 DeclKind.Unknown,
                 evolving,
@@ -3099,6 +3182,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
                 return new TypeModel(
                     declarationName,
                     declarationName,
+                    typeSymbol,
                     serializerName,
                     DeclKind.Unknown,
                     evolving,
@@ -3117,6 +3201,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 DeclKind.Enum,
                 evolving,
@@ -3133,6 +3218,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
                 return new TypeModel(
                     declarationName,
                     declarationName,
+                    typeSymbol,
                     serializerName,
                     DeclKind.Unknown,
                     evolving,
@@ -3158,6 +3244,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 DeclKind.Union,
                 evolving,
@@ -3179,6 +3266,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 kind,
                 evolving,
@@ -3197,6 +3285,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return new TypeModel(
                 declarationName,
                 declarationName,
+                typeSymbol,
                 serializerName,
                 kind,
                 evolving,
@@ -3272,6 +3361,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         return new TypeModel(
             declarationName,
             declarationName,
+            typeSymbol,
             serializerName,
             kind,
             evolving,
@@ -3307,6 +3397,8 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             out DeclKind targetKind);
         string targetTypeName = targetSymbol.ToDisplayString(FullNameFormat);
         List<MemberModel> members = [];
+        List<GraphFieldModel> graphFields = [];
+        HashSet<ISymbol> countedTargetFields = new(SymbolEqualityComparer.Default);
         if (validDeclaration && validTarget)
         {
             foreach (IPropertySymbol schemaProperty in declaration.GetMembers().OfType<IPropertySymbol>())
@@ -3316,12 +3408,60 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
                     continue;
                 }
 
+                if (TryGetIgnoredField(schemaProperty, out AttributeData? fieldAttribute))
+                {
+                    if (IgnoredFieldHasWireOptions(fieldAttribute!))
+                    {
+                        diagnostics.Add(Diagnostic.Create(
+                            InvalidIgnoredField,
+                            schemaProperty.Locations.FirstOrDefault(location => location.IsInSource),
+                            schemaProperty.Name,
+                            "Id and Type cannot be combined with Ignore"));
+                        continue;
+                    }
+
+                    Location? ignoredLocation = schemaProperty.Locations.FirstOrDefault(
+                        location => location.IsInSource);
+                    if (schemaProperty.Type.IsRefLikeType)
+                    {
+                        diagnostics.Add(Diagnostic.Create(
+                            InvalidExternalMember,
+                            ignoredLocation,
+                            schemaProperty.Name,
+                            targetTypeName,
+                            "a ref-like type cannot represent target object storage"));
+                        continue;
+                    }
+
+                    if (RequiresExternAlias(schemaProperty.Type, compilation))
+                    {
+                        diagnostics.Add(Diagnostic.Create(
+                            UnsupportedExternalAlias,
+                            ignoredLocation,
+                            schemaProperty.Type.ToDisplayString(FullNameFormat)));
+                        continue;
+                    }
+
+                    IFieldSymbol? ignoredTargetField = MatchingPublicTargetField(
+                        target!,
+                        schemaProperty);
+                    graphFields.Add(new GraphFieldModel(
+                        FieldGraphMemoryExpr(schemaProperty.Type, ignoredTargetField)));
+                    if (ignoredTargetField is not null)
+                    {
+                        countedTargetFields.Add(ignoredTargetField);
+                    }
+
+                    continue;
+                }
+
                 if (!TryBindExternalMember(
                         compilation,
                         target!,
                         schemaProperty,
                         targetTypeName,
-                        diagnostics))
+                        diagnostics,
+                        out ISymbol? targetMember))
                 {
                     continue;
                 }
@@ -3335,6 +3475,13 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
                 if (member is not null)
                 {
                     members.Add(member);
+                    IFieldSymbol? targetField = targetMember as IFieldSymbol;
+                    graphFields.Add(new GraphFieldModel(
+                        FieldGraphMemoryExpr(schemaProperty.Type, targetField)));
+                    if (targetField is not null)
+                    {
+                        countedTargetFields.Add(targetField);
+                    }
                 }
                 else if (diagnostics.Count == diagnosticCount)
                 {
@@ -3346,6 +3493,17 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
                         "the schema property type or descriptor is not supported"));
                 }
             }
+
+            foreach (IFieldSymbol targetField in PublicInstanceFields(target!))
+            {
+                if (!countedTargetFields.Add(targetField))
+                {
+                    continue;
+                }
+
+                graphFields.Add(new GraphFieldModel(
+                    FieldGraphMemoryExpr(targetField.Type, targetField)));
+            }
         }
 
         ImmutableArray<MemberModel> ordered = members
@@ -3354,13 +3512,15 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         return new TypeModel(
             declarationName,
             targetTypeName,
+            targetSymbol,
             serializerName,
             targetKind,
             evolving,
             declarationLocation,
             ordered,
             SortMembers(ordered),
-            diagnostics.ToImmutableArray());
+            diagnostics.ToImmutableArray(),
+            graphFields: graphFields.ToImmutableArray());
     }
 
     private static TypeModel BuildExternalEnumModel(
@@ -3392,6 +3552,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         return new TypeModel(
             declarationName,
             targetSymbol.ToDisplayString(FullNameFormat),
+            targetSymbol,
             serializerName,
             DeclKind.Enum,
             true,
@@ -3692,8 +3853,10 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         INamedTypeSymbol target,
         IPropertySymbol schemaProperty,
         string targetTypeName,
-        List<Diagnostic> diagnostics)
+        List<Diagnostic> diagnostics,
+        out ISymbol? targetMember)
     {
+        targetMember = null;
         Location? location = schemaProperty.Locations.FirstOrDefault(sourceLocation => sourceLocation.IsInSource);
         if (schemaProperty.Type.TypeKind is TypeKind.Pointer or TypeKind.FunctionPointer ||
             schemaProperty.Type.IsRefLikeType)
@@ -3707,7 +3870,7 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             return false;
         }
 
-        if (!TryFindTargetMember(target, schemaProperty.Name, out ISymbol? targetMember, out string reason))
+        if (!TryFindTargetMember(target, schemaProperty.Name, out targetMember, out string reason))
         {
             diagnostics.Add(Diagnostic.Create(
                 InvalidExternalMember,
@@ -3793,6 +3956,50 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         return true;
     }
 
+    private static IEnumerable<IFieldSymbol> PublicInstanceFields(INamedTypeSymbol target)
+    {
+        for (INamedTypeSymbol? current = target; current is not null; current = current.BaseType)
+        {
+            foreach (IFieldSymbol field in current.GetMembers()
+                         .OfType<IFieldSymbol>()
+                         .Where(field =>
+                             !field.IsImplicitlyDeclared &&
+                             !field.IsStatic &&
+                             !field.IsConst &&
+                             field.DeclaredAccessibility == Accessibility.Public)
+                         .OrderBy(field => field.MetadataName, StringComparer.Ordinal))
+            {
+                yield return field;
+            }
+        }
+    }
+
+    private static IFieldSymbol? MatchingPublicTargetField(
+        INamedTypeSymbol target,
+        IPropertySymbol schemaProperty)
+    {
+        if (!TryFindTargetMember(
+                target,
+                schemaProperty.Name,
+                out ISymbol? targetMember,
+                out _) ||
+            targetMember is not IFieldSymbol targetField)
+        {
+            return null;
+        }
+
+        if (targetField.IsImplicitlyDeclared ||
+            targetField.IsStatic ||
+            targetField.IsConst ||
+            targetField.DeclaredAccessibility != Accessibility.Public ||
+            !ExternalMemberTypesMatch(schemaProperty.Type, targetField.Type))
+        {
+            return null;
+        }
+
+        return targetField;
+    }
+
     private static bool TryFindTargetMember(
         INamedTypeSymbol target,
         string name,
@@ -4456,6 +4663,48 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         return attributeKind;
     }
 
+    private static bool TryGetIgnoredField(
+        ISymbol member,
+        out AttributeData? fieldAttribute)
+    {
+        fieldAttribute = null;
+        foreach (AttributeData attribute in member.GetAttributes())
+        {
+            if (!string.Equals(
+                    attribute.AttributeClass?.ToDisplayString(),
+                    "Apache.Fory.ForyFieldAttribute",
+                    StringComparison.Ordinal))
+            {
+                continue;
+            }
+
+            fieldAttribute = attribute;
+            foreach (KeyValuePair<string, TypedConstant> namedArgument in attribute.NamedArguments)
+            {
+                if (string.Equals(namedArgument.Key, "Ignore", StringComparison.Ordinal) &&
+                    namedArgument.Value.Value is true)
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        return false;
+    }
+
+    private static bool IgnoredFieldHasWireOptions(AttributeData fieldAttribute)
+    {
+        if (!fieldAttribute.ConstructorArguments.IsEmpty)
+        {
+            return true;
+        }
+
+        return fieldAttribute.NamedArguments.Any(
+            argument => argument.Key is "Id" or "Type");
+    }
+
     private static MemberModel? BuildMemberModel(
         string name,
         ITypeSymbol memberType,
@@ -5848,11 +6097,163 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         public ImmutableArray<FieldCodecModel> Generics { get; }
     }
 
+    private sealed class RuntimeTypeComparer : IEqualityComparer<ITypeSymbol>
+    {
+        // CLR registration erases source-only distinctions such as tuple names,
+        // dynamic, and native-integer aliases; equality and hashing must erase
+        // the same distinctions before selecting one generated owner.
+        public static readonly RuntimeTypeComparer Instance = new();
+
+        public bool Equals(ITypeSymbol? left, ITypeSymbol? right)
+        {
+            if (ReferenceEquals(left, right))
+            {
+                return true;
+            }
+
+            if (left is null || right is null)
+            {
+                return false;
+            }
+
+            if (IsDynamicOrObject(left) || IsDynamicOrObject(right))
+            {
+                return IsDynamicOrObject(left) && IsDynamicOrObject(right);
+            }
+
+            if (left is IArrayTypeSymbol || right is IArrayTypeSymbol)
+            {
+                if (left is not IArrayTypeSymbol leftArray ||
+                    right is not IArrayTypeSymbol rightArray)
+                {
+                    return false;
+                }
+
+                return leftArray.Rank == rightArray.Rank &&
+                       leftArray.IsSZArray == rightArray.IsSZArray &&
+                       Equals(leftArray.ElementType, rightArray.ElementType);
+            }
+
+            if (left is INamedTypeSymbol leftNamed &&
+                right is INamedTypeSymbol rightNamed)
+            {
+                return NamedEquals(
+                    NormalizeNamed(leftNamed),
+                    NormalizeNamed(rightNamed));
+            }
+
+            return SymbolEqualityComparer.Default.Equals(left, right);
+        }
+
+        public int GetHashCode(ITypeSymbol type)
+        {
+            if (IsDynamicOrObject(type))
+            {
+                return (int)SpecialType.System_Object;
+            }
+
+            if (type is IArrayTypeSymbol array)
+            {
+                int hash = CombineHash(17, (int)TypeKind.Array);
+                hash = CombineHash(hash, array.Rank);
+                hash = CombineHash(hash, array.IsSZArray ? 1 : 0);
+                return CombineHash(hash, GetHashCode(array.ElementType));
+            }
+
+            if (type is INamedTypeSymbol named)
+            {
+                return NamedHash(NormalizeNamed(named));
+            }
+
+            return SymbolEqualityComparer.Default.GetHashCode(type);
+        }
+
+        private bool NamedEquals(
+            INamedTypeSymbol left,
+            INamedTypeSymbol right)
+        {
+            if (!SymbolEqualityComparer.Default.Equals(
+                    left.OriginalDefinition,
+                    right.OriginalDefinition) ||
+                left.TypeArguments.Length != right.TypeArguments.Length)
+            {
+                return false;
+            }
+
+            INamedTypeSymbol? leftContaining = left.ContainingType;
+            INamedTypeSymbol? rightContaining = right.ContainingType;
+            if ((leftContaining is null) != (rightContaining is null) ||
+                leftContaining is not null &&
+                !Equals(leftContaining, rightContaining))
+            {
+                return false;
+            }
+
+            for (int i = 0; i < left.TypeArguments.Length; i++)
+            {
+                if (!Equals(left.TypeArguments[i], right.TypeArguments[i]))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private int NamedHash(INamedTypeSymbol type)
+        {
+            int hash = CombineHash(
+                23,
+                SymbolEqualityComparer.Default.GetHashCode(type.OriginalDefinition));
+            if (type.ContainingType is not null)
+            {
+                hash = CombineHash(hash, GetHashCode(type.ContainingType));
+            }
+
+            foreach (ITypeSymbol typeArgument in type.TypeArguments)
+            {
+                hash = CombineHash(hash, GetHashCode(typeArgument));
+            }
+
+            return hash;
+        }
+
+        private static INamedTypeSymbol NormalizeNamed(INamedTypeSymbol type)
+        {
+            type = NormalizeTuple(type);
+
+            if (type.IsNativeIntegerType &&
+                type.NativeIntegerUnderlyingType is INamedTypeSymbol nativeUnderlying)
+            {
+                return nativeUnderlying;
+            }
+
+            return type;
+        }
+
+        private static INamedTypeSymbol NormalizeTuple(INamedTypeSymbol type)
+        {
+            return type.TupleUnderlyingType ?? type;
+        }
+
+        private static bool IsDynamicOrObject(ITypeSymbol type)
+        {
+            return type.TypeKind == TypeKind.Dynamic ||
+                   type.SpecialType == SpecialType.System_Object;
+        }
+
+        private static int CombineHash(int current, int value)
+        {
+            return unchecked(current * 31 + value);
+        }
+    }
+
     private sealed class TypeModel
     {
         public TypeModel(
             string declarationName,
             string targetTypeName,
+            ITypeSymbol targetType,
             string serializerName,
             DeclKind kind,
             bool evolving,
@@ -5860,10 +6261,12 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             ImmutableArray<MemberModel> members,
             ImmutableArray<MemberModel> sortedMembers,
             ImmutableArray<Diagnostic> diagnostics,
-            ImmutableArray<UnionCaseModel> unionCases = default)
+            ImmutableArray<UnionCaseModel> unionCases = default,
+            ImmutableArray<GraphFieldModel> graphFields = default)
         {
             DeclarationName = declarationName;
             TargetTypeName = targetTypeName;
+            TargetType = targetType;
             SerializerName = serializerName;
             Kind = kind;
             Evolving = evolving;
@@ -5874,10 +6277,16 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
             UnionCases = unionCases.IsDefault
                 ? ImmutableArray<UnionCaseModel>.Empty
                 : unionCases;
+            GraphFields = graphFields.IsDefault
+                ? sortedMembers
+                    .Select(member => new GraphFieldModel(FieldGraphMemoryExpr(member)))
+                    .ToImmutableArray()
+                : graphFields;
         }
 
         public string DeclarationName { get; }
         public string TargetTypeName { get; }
+        public ITypeSymbol TargetType { get; }
         public string SerializerName { get; }
         public DeclKind Kind { get; }
         public bool Evolving { get; }
@@ -5886,6 +6295,17 @@ public sealed class ForyModelGenerator : IIncrementalGenerator
         public ImmutableArray<MemberModel> SortedMembers { get; }
         public ImmutableArray<Diagnostic> Diagnostics { get; }
         public ImmutableArray<UnionCaseModel> UnionCases { get; }
+        public ImmutableArray<GraphFieldModel> GraphFields { get; }
+    }
+
+    private sealed class GraphFieldModel
+    {
+        public GraphFieldModel(string memoryExpression)
+        {
+            MemoryExpression = memoryExpression;
+        }
+
+        public string MemoryExpression { get; }
     }
 
     private sealed class MemberModel
diff --git a/csharp/src/Fory/Attributes.cs b/csharp/src/Fory/Attributes.cs
index 644721f4a..efa494aab 100644
--- a/csharp/src/Fory/Attributes.cs
+++ b/csharp/src/Fory/Attributes.cs
@@ -39,6 +39,8 @@ public sealed class ForyStructAttribute : Attribute
 
 /// <summary>
 /// Marks an enum as a generated Fory enum type, or declares an external enum serializer.
+/// Enum numeric values are the wire tags and must be in the range
+/// <c>0</c> through <see cref="uint.MaxValue"/>.
 /// </summary>
 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Enum)]
 public sealed class ForyEnumAttribute : Attribute
@@ -140,6 +142,12 @@ public sealed class ForyFieldAttribute : Attribute
     /// </summary>
     public Type? Type { get; set; }
 
+    /// <summary>
+    /// Gets or sets whether an external serializer declaration excludes this member from
+    /// the wire schema while retaining its storage in the graph-memory estimate.
+    /// </summary>
+    public bool Ignore { get; set; }
+
     private static void ValidateId(short id)
     {
         if (id < 0)
diff --git a/csharp/tests/Fory.ExternalTypes/ExternalModels.cs b/csharp/tests/Fory.ExternalTypes/ExternalModels.cs
index 63e6f3977..7b06f507f 100644
--- a/csharp/tests/Fory.ExternalTypes/ExternalModels.cs
+++ b/csharp/tests/Fory.ExternalTypes/ExternalModels.cs
@@ -145,6 +145,41 @@ public sealed class ExternalEvolutionOff
     public int Value { get; set; }
 }
 
+public struct ExternalBudgetValue
+{
+    public long Left;
+
+    public long Right;
+}
+
+public class ExternalBudgetBase
+{
+    public ExternalBudgetValue BaseState;
+}
+
+public sealed class ExternalBudgetModel : ExternalBudgetBase
+{
+    public int Value;
+
+    public ExternalBudgetValue PublicState;
+
+    private readonly ExternalBudgetValue HiddenState;
+
+    public ExternalBudgetModel()
+    {
+    }
+
+    public ExternalBudgetModel(ExternalBudgetValue hiddenState)
+    {
+        HiddenState = hiddenState;
+    }
+
+    public ExternalBudgetValue ReadHiddenState()
+    {
+        return HiddenState;
+    }
+}
+
 public sealed class XlangUser
 {
     public int Id { get; set; }
@@ -162,8 +197,8 @@ public struct XlangPoint
 public enum XlangStatus : uint
 {
     Unknown = 0,
-    Ready = 1,
-    Done = 2,
+    Ready = 7,
+    Done = 23,
 }
 
 public sealed class XlangHolder
diff --git a/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs b/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs
index 37f1d689b..679aacb4a 100644
--- a/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs
+++ b/csharp/tests/Fory.Tests/ExternalTypeSerializers.cs
@@ -144,6 +144,16 @@ internal abstract class ExternalEvolutionOffSerializer
     public abstract int Value { get; }
 }
 
+[ForyStruct(Target = typeof(ExternalBudgetModel))]
+internal abstract class ExternalBudgetModelSerializer
+{
+    [ForyField(1)]
+    public abstract int Value { get; }
+
+    [ForyField(Ignore = true)]
+    public abstract ExternalBudgetValue HiddenState { get; }
+}
+
 [ForyStruct]
 internal sealed class OrdinaryUser
 {
diff --git a/csharp/tests/Fory.Tests/ForyGeneratorTests.cs b/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
index 42338b87a..5248dc853 100644
--- a/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
+++ b/csharp/tests/Fory.Tests/ForyGeneratorTests.cs
@@ -377,6 +377,50 @@ public sealed class ForyGeneratorTests
             StringComparison.Ordinal);
     }
 
+    [Fact]
+    public void ExternalIgnoredFieldsOnlyAffectGraphMemory()
+    {
+        const string source = """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+
+            public struct LargeValue
+            {
+                public long Left;
+                public long Right;
+            }
+
+            public sealed class ExternalTarget
+            {
+                public int Value;
+                public LargeValue PublicState;
+                private readonly LargeValue HiddenState;
+
+                public LargeValue ReadHiddenState() => HiddenState;
+            }
+
+            [ForyStruct(Target = typeof(ExternalTarget))]
+            internal abstract class ExternalTargetSerializer
+            {
+                [ForyField(1)]
+                public abstract int Value { get; }
+
+                [ForyField(Ignore = true)]
+                public abstract LargeValue HiddenState { get; }
+            }
+            """;
+
+        string generated = GenerateSource(source);
+        const string largeValueSize =
+            "global::System.Runtime.CompilerServices.Unsafe.SizeOf<global::GeneratedDiagnostics.LargeValue>()";
+
+        Assert.Equal(
+            2,
+            generated.Split(largeValueSize, StringSplitOptions.None).Length - 1);
+        Assert.Contains("value.Value", generated, StringComparison.Ordinal);
+        Assert.DoesNotContain("HiddenState", generated, StringComparison.Ordinal);
+    }
+
     public static TheoryData<string, string> ExternalDiagnosticCases => new()
     {
         {
@@ -694,6 +738,33 @@ public sealed class ForyGeneratorTests
             """,
             "FORY014"
         },
+        {
+            """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+            [ForyStruct]
+            public sealed class InvalidTarget
+            {
+                [ForyField(Ignore = true)]
+                public int Value { get; set; }
+            }
+            """,
+            "FORY015"
+        },
+        {
+            """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+            public sealed class InvalidTarget { public int Value; }
+            [ForyStruct(Target = typeof(InvalidTarget))]
+            internal abstract class InvalidSerializer
+            {
+                [ForyField(1, Ignore = true)]
+                public abstract int Value { get; }
+            }
+            """,
+            "FORY015"
+        },
         {
             """
             using Apache.Fory;
@@ -774,6 +845,131 @@ public sealed class ForyGeneratorTests
             diagnostic => diagnostic.Id == "FORY013");
     }
 
+    [Fact]
+    public void DynamicAndObjectTargetsAreDuplicate()
+    {
+        const string source = """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+
+            [ForyStruct(Target = typeof(Fory.ExternalTypes.ExternalBox<dynamic>))]
+            internal abstract class DynamicBoxSerializer
+            {
+            }
+
+            [ForyStruct(Target = typeof(Fory.ExternalTypes.ExternalBox<object>))]
+            internal abstract class ObjectBoxSerializer
+            {
+            }
+            """;
+        CSharpCompilation compilation = CreateCompilation(source);
+        GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+        driver = driver.RunGeneratorsAndUpdateCompilation(
+            compilation,
+            out _,
+            out _);
+
+        Assert.Equal(
+            2,
+            driver.GetRunResult().Diagnostics
+                .Count(diagnostic => diagnostic.Id == "FORY010"));
+    }
+
+    [Fact]
+    public void TupleNamesShareTargetIdentity()
+    {
+        const string source = """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+
+            [ForyStruct(Target = typeof(
+                Fory.ExternalTypes.ExternalBox<(int Left, string Name)>))]
+            internal abstract class NamedTupleBoxSerializer
+            {
+            }
+
+            [ForyStruct(Target = typeof(
+                Fory.ExternalTypes.ExternalBox<(int X, string Y)>))]
+            internal abstract class OtherTupleBoxSerializer
+            {
+            }
+            """;
+        CSharpCompilation compilation = CreateCompilation(source);
+        GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+        driver = driver.RunGeneratorsAndUpdateCompilation(
+            compilation,
+            out _,
+            out _);
+
+        Assert.Equal(
+            2,
+            driver.GetRunResult().Diagnostics
+                .Count(diagnostic => diagnostic.Id == "FORY010"));
+    }
+
+    [Fact]
+    public void NativeIntsShareTargetIdentity()
+    {
+        const string source = """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+
+            [ForyStruct(Target = typeof(Fory.ExternalTypes.ExternalBox<nint>))]
+            internal abstract class NativeIntBoxSerializer
+            {
+            }
+
+            [ForyStruct(Target = typeof(
+                Fory.ExternalTypes.ExternalBox<System.IntPtr>))]
+            internal abstract class IntPtrBoxSerializer
+            {
+            }
+            """;
+        CSharpCompilation compilation = CreateCompilation(source);
+        GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+        driver = driver.RunGeneratorsAndUpdateCompilation(
+            compilation,
+            out _,
+            out _);
+
+        Assert.Equal(
+            2,
+            driver.GetRunResult().Diagnostics
+                .Count(diagnostic => diagnostic.Id == "FORY010"));
+    }
+
+    [Fact]
+    public void ArrayElementsShareTargetIdentity()
+    {
+        const string source = """
+            using Apache.Fory;
+            namespace GeneratedDiagnostics;
+
+            [ForyStruct(Target = typeof(
+                Fory.ExternalTypes.ExternalBox<dynamic[]>))]
+            internal abstract class DynamicArrayBoxSerializer
+            {
+            }
+
+            [ForyStruct(Target = typeof(
+                Fory.ExternalTypes.ExternalBox<object[]>))]
+            internal abstract class ObjectArrayBoxSerializer
+            {
+            }
+            """;
+        CSharpCompilation compilation = CreateCompilation(source);
+        GeneratorDriver driver = CSharpGeneratorDriver.Create(new ForyModelGenerator());
+        driver = driver.RunGeneratorsAndUpdateCompilation(
+            compilation,
+            out _,
+            out _);
+
+        Assert.Equal(
+            2,
+            driver.GetRunResult().Diagnostics
+                .Count(diagnostic => diagnostic.Id == "FORY010"));
+    }
+
     [Fact]
     public void InternalTargetWithIvtCompiles()
     {
diff --git a/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs b/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs
index e2bcfd813..9659c00e1 100644
--- a/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs
+++ b/csharp/tests/Fory.Tests/GraphMemoryBudgetTests.cs
@@ -70,6 +70,12 @@ public sealed class BudgetValueHolder
     public BudgetValue Value { get; set; }
 }
 
+[ForyStruct]
+public sealed class NullableValueHolder
+{
+    public int? Value { get; set; }
+}
+
 [ForyStruct]
 public sealed class BudgetValueCompatWriter
 {
@@ -164,6 +170,7 @@ public sealed class GraphMemoryBudgetTests
             .Register<GeneratedPackedListBudget>(1007)
             .Register<GeneratedSchemaMapBudget>(1008)
             .Register<BudgetValueHolder>(1009)
+            .Register<NullableValueHolder>(1015)
             .Register<BudgetSelfNode>(1012);
     }
 
@@ -254,6 +261,45 @@ public sealed class GraphMemoryBudgetTests
         Assert.Equal(value, exact.Deserialize<List<ExternalPoint>>(bytes));
     }
 
+    [Fact]
+    public void ExternalClassStorageBudget()
+    {
+        ExternalBudgetValue publicState = new() { Left = 1, Right = 2 };
+        ExternalBudgetValue hiddenState = new() { Left = 3, Right = 4 };
+        ExternalBudgetModel value = new(hiddenState)
+        {
+            Value = 7,
+            PublicState = publicState,
+            BaseState = new ExternalBudgetValue { Left = 5, Right = 6 },
+        };
+        ForyRuntime writer = ForyRuntime.Builder().Compatible(false).Build();
+        writer.Register<ExternalBudgetModel>(1014);
+        byte[] bytes = writer.Serialize(value);
+        long required =
+            ObjectOwnerBytes
+            + sizeof(int)
+            + 3L * Unsafe.SizeOf<ExternalBudgetValue>();
+
+        ForyRuntime tooSmall = ForyRuntime.Builder()
+            .Compatible(false)
+            .MaxGraphMemoryBytes(required - 1)
+            .Build();
+        tooSmall.Register<ExternalBudgetModel>(1014);
+        Assert.Throws<InvalidDataException>(
+            () => tooSmall.Deserialize<ExternalBudgetModel>(bytes));
+
+        ForyRuntime exact = ForyRuntime.Builder()
+            .Compatible(false)
+            .MaxGraphMemoryBytes(required)
+            .Build();
+        exact.Register<ExternalBudgetModel>(1014);
+        ExternalBudgetModel decoded = exact.Deserialize<ExternalBudgetModel>(bytes);
+        Assert.Equal(value.Value, decoded.Value);
+        Assert.Equal(default(ExternalBudgetValue), decoded.PublicState);
+        Assert.Equal(default(ExternalBudgetValue), decoded.BaseState);
+        Assert.Equal(default(ExternalBudgetValue), decoded.ReadHiddenState());
+    }
+
     [Fact]
     public void ReadOnlySequenceUsesSameBudget()
     {
@@ -388,6 +434,20 @@ public sealed class GraphMemoryBudgetTests
         Assert.Equal(holder.Value.Id, NewFory(BudgetValueHolderBytes).Deserialize<BudgetValueHolder>(holderBytes).Value.Id);
     }
 
+    [Fact]
+    public void NullableValueStorageUsesFullWidth()
+    {
+        NullableValueHolder holder = new() { Value = 9 };
+        byte[] bytes = Serialize(holder);
+        long required = ObjectOwnerBytes + Unsafe.SizeOf<int?>();
+
+        Assert.Throws<InvalidDataException>(
+            () => NewFory(required - 1).Deserialize<NullableValueHolder>(bytes));
+        Assert.Equal(
+            holder.Value,
+            NewFory(required).Deserialize<NullableValueHolder>(bytes).Value);
+    }
+
     [Fact]
     public void GeneratedSchemaContainersAreCharged()
     {
diff --git a/dart/packages/fory/CHANGELOG.md b/dart/packages/fory/CHANGELOG.md
index 37de8770c..eeb9729eb 100644
--- a/dart/packages/fory/CHANGELOG.md
+++ b/dart/packages/fory/CHANGELOG.md
@@ -3,6 +3,9 @@
 - Start the next development cycle after the 1.4.0 release.
 - Add generated external-type serialization with direct target construction
   and existing registration, carrier, reference, and schema-evolution support.
+- Replace `ForyField.skip` with `ForyField.ignore`, remove `skip` from container
+  field annotations, and include ignored and discoverable external target
+  fields in graph-memory accounting.
 
 ## 1.4.0
 
diff --git a/dart/packages/fory/README.md b/dart/packages/fory/README.md
index 6162f1106..9b4206790 100644
--- a/dart/packages/fory/README.md
+++ b/dart/packages/fory/README.md
@@ -111,7 +111,11 @@ abstract final class UserSerializer {
 }
 ```
 
-The declaration is schema-only. Register and serialize the target:
+The declaration is schema-only.
+Use `@ForyField(ignore: true)` for declaration-only storage that should count
+toward the graph-memory budget without being serialized.
+
+Register and serialize the target:
 
 ```dart
 ExternalSerializersForyModule.register(
@@ -200,7 +204,7 @@ class NodeList {
 
 | Option     | Description                                      |
 | ---------- | ------------------------------------------------ |
-| `skip`     | Skip the field during serialization              |
+| `ignore`   | Exclude the field from serialization             |
 | `id`       | Stable field ID for compatible-mode evolution    |
 | `nullable` | Override nullability inference                   |
 | `ref`      | Enable reference tracking for this field         |
diff --git a/dart/packages/fory/lib/src/annotation/fory_field.dart b/dart/packages/fory/lib/src/annotation/fory_field.dart
index c68e10842..6df0a0928 100644
--- a/dart/packages/fory/lib/src/annotation/fory_field.dart
+++ b/dart/packages/fory/lib/src/annotation/fory_field.dart
@@ -20,7 +20,7 @@
 import 'package:fory/src/annotation/type_spec.dart';
 
 final class ForyField {
-  final bool skip;
+  final bool ignore;
   final int? id;
   final bool? nullable;
   final bool ref;
@@ -28,17 +28,24 @@ final class ForyField {
   final TypeSpec? type;
 
   const ForyField({
-    this.skip = false,
+    this.ignore = false,
     this.id,
     this.nullable,
     this.ref = false,
     this.dynamic,
     this.type,
-  });
+  }) : assert(
+         !ignore ||
+             (id == null &&
+                 nullable == null &&
+                 !ref &&
+                 dynamic == null &&
+                 type == null),
+         'Ignored fields cannot define wire metadata.',
+       );
 }
 
 final class ListField {
-  final bool skip;
   final int? id;
   final bool? nullable;
   final bool ref;
@@ -46,7 +53,6 @@ final class ListField {
   final TypeSpec? element;
 
   const ListField({
-    this.skip = false,
     this.id,
     this.nullable,
     this.ref = false,
@@ -56,7 +62,6 @@ final class ListField {
 }
 
 final class ArrayField {
-  final bool skip;
   final int? id;
   final bool? nullable;
   final bool ref;
@@ -64,7 +69,6 @@ final class ArrayField {
   final TypeSpec element;
 
   const ArrayField({
-    this.skip = false,
     this.id,
     this.nullable,
     this.ref = false,
@@ -74,7 +78,6 @@ final class ArrayField {
 }
 
 final class SetField {
-  final bool skip;
   final int? id;
   final bool? nullable;
   final bool ref;
@@ -82,7 +85,6 @@ final class SetField {
   final TypeSpec? element;
 
   const SetField({
-    this.skip = false,
     this.id,
     this.nullable,
     this.ref = false,
@@ -92,7 +94,6 @@ final class SetField {
 }
 
 final class MapField {
-  final bool skip;
   final int? id;
   final bool? nullable;
   final bool ref;
@@ -101,7 +102,6 @@ final class MapField {
   final TypeSpec? value;
 
   const MapField({
-    this.skip = false,
     this.id,
     this.nullable,
     this.ref = false,
diff --git a/dart/packages/fory/lib/src/codegen/fory_generator.dart b/dart/packages/fory/lib/src/codegen/fory_generator.dart
index ee5f091d8..710c02b90 100644
--- a/dart/packages/fory/lib/src/codegen/fory_generator.dart
+++ b/dart/packages/fory/lib/src/codegen/fory_generator.dart
@@ -182,13 +182,16 @@ final class ForyGenerator extends Generator {
         element: element,
       );
     }
+    final declarationFields = element.fields
+        .where(_isStoredInstanceField)
+        .toList(growable: false);
 
     final targetType =
         annotatedTarget == null
             ? element.thisType
             : _validateExternalTarget(element, annotatedTarget);
     if (annotatedTarget != null) {
-      _validateExternalDeclaration(element, targetType);
+      _validateExternalDeclaration(element, targetType, declarationFields);
     }
     final targetTypeLiteral =
         annotatedTarget == null
@@ -202,16 +205,17 @@ final class ForyGenerator extends Generator {
       external: annotatedTarget != null,
     );
 
-    final fields = element.fields
-        .where(
-          (field) => !field.isStatic && identical(field.nonSynthetic, field),
-        )
-        .where((field) => !_isSkipped(field))
+    final fields = declarationFields
+        .where((field) => !_isIgnored(field))
         .map(
           (field) =>
               _analyzeField(element, targetType, targetTypeLiteral, field),
         )
         .toList(growable: false);
+    final graphFieldCount =
+        annotatedTarget == null
+            ? declarationFields.length
+            : _externalGraphFieldCount(element, targetType, declarationFields);
 
     final sortedFields = _sortFields(fields);
     final constructionModel = _buildConstructionModel(
@@ -228,6 +232,7 @@ final class ForyGenerator extends Generator {
       targetTypeLiteral: targetTypeLiteral,
       evolving: evolving,
       fields: sortedFields,
+      graphFieldCount: graphFieldCount,
       constructionModel: constructionModel,
     );
   }
@@ -303,6 +308,7 @@ final class ForyGenerator extends Generator {
   void _validateExternalDeclaration(
     ClassElement declaration,
     InterfaceType targetType,
+    List<FieldElement> declarationFields,
   ) {
     final targetName = targetType.getDisplayString();
     if (!declaration.isAbstract || !declaration.isFinal) {
@@ -321,12 +327,7 @@ final class ForyGenerator extends Generator {
         element: declaration,
       );
     }
-    for (final field in declaration.fields.where(
-      (field) =>
-          !field.isStatic &&
-          identical(field.nonSynthetic, field) &&
-          !_isSkipped(field),
-    )) {
+    for (final field in declarationFields) {
       if (!field.isLate || !field.isFinal || field.hasInitializer) {
         throw InvalidGenerationSourceError(
           'Schema field ${declaration.displayName}.${field.displayName} for '
@@ -1646,14 +1647,73 @@ final class ForyGenerator extends Generator {
   }
 
   int _graphObjectBytes(_GeneratedStructSpec structSpec) =>
-      _structObjectOwnerBytes + structSpec.fields.length * _referenceBytes;
+      _structObjectOwnerBytes + structSpec.graphFieldCount * _referenceBytes;
+
+  int _externalGraphFieldCount(
+    ClassElement declaration,
+    InterfaceType targetType,
+    List<FieldElement> declarationFields,
+  ) {
+    final publicFields = <FieldElement>[];
+    // Analyzer keeps each class's direct mixins separate from its superclass,
+    // so this visits every applied mixin storage slot exactly once.
+    for (
+      InterfaceType? current = targetType;
+      current != null;
+      current = current.superclass
+    ) {
+      _addPublicStoredFields(publicFields, current);
+      for (final mixin in current.mixins) {
+        _addPublicStoredFields(publicFields, mixin);
+      }
+    }
+
+    final claimed = <int>{};
+    for (final declarationField in declarationFields) {
+      final getter = targetType.lookUpGetter(
+        declarationField.displayName,
+        declaration.library,
+      );
+      final variable = getter?.variable;
+      if (variable is! FieldElement || !_isPublicStoredField(variable)) {
+        continue;
+      }
+      for (var index = 0; index < publicFields.length; index += 1) {
+        if (!claimed.contains(index) &&
+            identical(publicFields[index].baseElement, variable.baseElement)) {
+          claimed.add(index);
+          break;
+        }
+      }
+    }
+    return publicFields.length + declarationFields.length - claimed.length;
+  }
+
+  void _addPublicStoredFields(List<FieldElement> fields, InterfaceType type) {
+    fields.addAll(type.element.fields.where(_isPublicStoredField));
+  }
+
+  bool _isStoredInstanceField(FieldElement field) {
+    final baseField = field.baseElement;
+    return !field.isStatic &&
+        !field.isAbstract &&
+        !field.isExternal &&
+        (identical(baseField.nonSynthetic, baseField) ||
+            baseField.nonSynthetic is FieldFormalParameterElement);
+  }
+
+  bool _isPublicStoredField(FieldElement field) =>
+      field.isPublic && _isStoredInstanceField(field);
 
-  bool _isSkipped(FieldElement field) {
+  bool _isIgnored(FieldElement field) {
     final annotation = _fieldAnnotationOf(field);
-    if (annotation == null) {
+    final annotationType = annotation?.type;
+    if (annotation == null ||
+        annotationType == null ||
+        !_foryFieldChecker.isExactlyType(annotationType)) {
       return false;
     }
-    return ConstantReader(annotation).peek('skip')?.boolValue ?? false;
+    return ConstantReader(annotation).peek('ignore')?.boolValue ?? false;
   }
 
   String _fieldInfoLiteral(_GeneratedFieldSpec field) {
@@ -4243,6 +4303,7 @@ final class _GeneratedStructSpec {
   final String targetTypeLiteral;
   final bool evolving;
   final List<_GeneratedFieldSpec> fields;
+  final int graphFieldCount;
   final _ConstructionModel constructionModel;
 
   const _GeneratedStructSpec({
@@ -4251,6 +4312,7 @@ final class _GeneratedStructSpec {
     required this.targetTypeLiteral,
     required this.evolving,
     required this.fields,
+    required this.graphFieldCount,
     required this.constructionModel,
   });
 }
diff --git a/dart/packages/fory/test/external_type_generator_test.dart b/dart/packages/fory/test/external_type_generator_test.dart
index 8e749f803..e806428b2 100644
--- a/dart/packages/fory/test/external_type_generator_test.dart
+++ b/dart/packages/fory/test/external_type_generator_test.dart
@@ -310,6 +310,20 @@ abstract final class TargetSerializer {
     );
   });
 
+  test('validates ignored schema field shape', () async {
+    await _expectGenerationError(
+      targetSource: 'class Target {}',
+      declarationSource: '''
+@ForyStruct(target: Target)
+abstract final class TargetSerializer {
+  @ForyField(ignore: true)
+  late String hidden;
+}
+''',
+      message: 'must be a late final field without an initializer',
+    );
+  });
+
   test('rejects duplicate target', () async {
     await _expectGenerationError(
       targetSource: '''
@@ -552,6 +566,48 @@ abstract final class TargetSerializer {
     );
   });
 
+  test('accounts for external target storage without serializing it', () async {
+    await _expectGenerationOutput(
+      targetSource: '''
+mixin TargetMixin {
+  final bool mixed = false;
+}
+
+class Base<T> {
+  Base(this.inherited);
+
+  final T inherited;
+}
+
+class Target extends Base<String> with TargetMixin {
+  Target(this.value, String inherited) : super(inherited);
+
+  final int value;
+  final String omitted = 'omitted';
+  final Object _hidden = Object();
+}
+''',
+      declarationSource: '''
+@ForyStruct(target: Target)
+abstract final class TargetSerializer {
+  late final int value;
+  late final String inherited;
+
+  @ForyField(ignore: true)
+  late final Object _hidden;
+}
+''',
+      output: allOf(
+        contains('context.reserveGraphMemory(44);'),
+        contains("name: 'value',"),
+        contains("name: 'inherited',"),
+        isNot(contains("name: 'omitted',")),
+        isNot(contains('_hidden')),
+        isNot(contains("name: 'mixed',")),
+      ),
+    );
+  });
+
   test('renders nullable prefixed re-exported types', () async {
     await _expectGenerationOutput(
       targetSource: '''
diff --git a/dart/packages/fory/test/graph_memory_budget_test.dart b/dart/packages/fory/test/graph_memory_budget_test.dart
index eddc1279a..ee5218aac 100644
--- a/dart/packages/fory/test/graph_memory_budget_test.dart
+++ b/dart/packages/fory/test/graph_memory_budget_test.dart
@@ -55,6 +55,16 @@ class BudgetGeneratedEnvelope {
   Map<String, int> counts = <String, int>{};
 }
 
+@ForyStruct()
+class BudgetIgnoredEnvelope {
+  BudgetIgnoredEnvelope();
+
+  int value = 0;
+
+  @ForyField(ignore: true)
+  Object? ignored;
+}
+
 @ForyStruct()
 class BudgetSelfNode {
   BudgetSelfNode();
@@ -114,6 +124,14 @@ void _registerGenerated(Fory fory) {
   );
 }
 
+void _registerIgnored(Fory fory) {
+  GraphMemoryBudgetTestForyModule.register(
+    fory,
+    BudgetIgnoredEnvelope,
+    name: 'test.BudgetIgnoredEnvelope',
+  );
+}
+
 void _registerSelfNode(Fory fory) {
   GraphMemoryBudgetTestForyModule.register(
     fory,
@@ -241,6 +259,30 @@ void main() {
       expect(identical(roundTrip, roundTrip.children.single), isTrue);
     });
 
+    test('reserves ignored field storage', () {
+      final writer = Fory();
+      _registerIgnored(writer);
+      final bytes = writer.serialize(
+        BudgetIgnoredEnvelope()
+          ..value = 7
+          ..ignored = Object(),
+      );
+      final required = _objectGraphBytes(2);
+
+      final failingReader = Fory(maxGraphMemoryBytes: required - 1);
+      _registerIgnored(failingReader);
+      expect(
+        () => failingReader.deserialize<BudgetIgnoredEnvelope>(bytes),
+        _throwsGraphBudget,
+      );
+
+      final passingReader = Fory(maxGraphMemoryBytes: required);
+      _registerIgnored(passingReader);
+      final roundTrip = passingReader.deserialize<BudgetIgnoredEnvelope>(bytes);
+      expect(roundTrip.value, equals(7));
+      expect(roundTrip.ignored, isNull);
+    });
+
     test('reserves map entries', () {
       final value = <Object?, Object?>{'a': 1};
 
diff --git a/dart/packages/fory/test/runtime_validation_test.dart b/dart/packages/fory/test/runtime_validation_test.dart
index 5fa7c64a0..13a74ce2a 100644
--- a/dart/packages/fory/test/runtime_validation_test.dart
+++ b/dart/packages/fory/test/runtime_validation_test.dart
@@ -90,35 +90,35 @@ class ExplicitUnknownEnvelope {
 }
 
 @ForyStruct()
-class SkipEnvelope {
-  SkipEnvelope();
+class IgnoreEnvelope {
+  IgnoreEnvelope();
 
   String visible = '';
 
-  @ForyField(skip: true)
-  String skipped = 'local-default';
+  @ForyField(ignore: true)
+  String ignored = 'local-default';
 }
 
 @ForyStruct()
-class SkipCompatibleV1 {
-  SkipCompatibleV1();
+class IgnoreCompatibleV1 {
+  IgnoreCompatibleV1();
 
   @ForyField(id: 1)
   String visible = '';
 
   @ForyField(id: 2)
-  String skipped = 'writer-default';
+  String ignored = 'writer-default';
 }
 
 @ForyStruct()
-class SkipCompatibleV2 {
-  SkipCompatibleV2();
+class IgnoreCompatibleV2 {
+  IgnoreCompatibleV2();
 
   @ForyField(id: 1)
   String visible = '';
 
-  @ForyField(id: 2, skip: true)
-  String skipped = 'reader-default';
+  @ForyField(ignore: true)
+  String ignored = 'reader-default';
 }
 
 @ForyStruct()
@@ -170,24 +170,24 @@ void _registerValidationTypes(Fory fory) {
   );
   RuntimeValidationTestForyModule.register(
     fory,
-    SkipEnvelope,
-    name: 'validation.SkipEnvelope',
+    IgnoreEnvelope,
+    name: 'validation.IgnoreEnvelope',
   );
 }
 
-void _registerSkipV1(Fory fory) {
+void _registerIgnoreV1(Fory fory) {
   RuntimeValidationTestForyModule.register(
     fory,
-    SkipCompatibleV1,
-    name: 'validation.SkipCompatible',
+    IgnoreCompatibleV1,
+    name: 'validation.IgnoreCompatible',
   );
 }
 
-void _registerSkipV2(Fory fory) {
+void _registerIgnoreV2(Fory fory) {
   RuntimeValidationTestForyModule.register(
     fory,
-    SkipCompatibleV2,
-    name: 'validation.SkipCompatible',
+    IgnoreCompatibleV2,
+    name: 'validation.IgnoreCompatible',
   );
 }
 
@@ -236,20 +236,20 @@ void main() {
       );
     });
 
-    test('skip fields stay local only after round trip', () {
+    test('ignored fields stay local only after round trip', () {
       final fory = Fory();
       _registerValidationTypes(fory);
 
-      final roundTrip = fory.deserialize<SkipEnvelope>(
+      final roundTrip = fory.deserialize<IgnoreEnvelope>(
         fory.serialize(
-          SkipEnvelope()
+          IgnoreEnvelope()
             ..visible = 'kept'
-            ..skipped = 'discarded',
+            ..ignored = 'discarded',
         ),
       );
 
       expect(roundTrip.visible, equals('kept'));
-      expect(roundTrip.skipped, equals('local-default'));
+      expect(roundTrip.ignored, equals('local-default'));
     });
 
     test('dynamic fields preserve concrete runtime payload types', () {
@@ -289,22 +289,22 @@ void main() {
       expect(roundTrip.value, equals('dynamic-payload'));
     });
 
-    test('compatible mode ignores skipped fields from older writers', () {
+    test('compatible mode ignores fields from older writers', () {
       final writer = Fory(compatible: true);
       final reader = Fory(compatible: true);
-      _registerSkipV1(writer);
-      _registerSkipV2(reader);
+      _registerIgnoreV1(writer);
+      _registerIgnoreV2(reader);
 
-      final migrated = reader.deserialize<SkipCompatibleV2>(
+      final migrated = reader.deserialize<IgnoreCompatibleV2>(
         writer.serialize(
-          SkipCompatibleV1()
+          IgnoreCompatibleV1()
             ..visible = 'seen'
-            ..skipped = 'legacy',
+            ..ignored = 'legacy',
         ),
       );
 
       expect(migrated.visible, equals('seen'));
-      expect(migrated.skipped, equals('reader-default'));
+      expect(migrated.ignored, equals('reader-default'));
     });
   });
 
diff --git a/dart/packages/fory/test/type_spec_annotation_test.dart b/dart/packages/fory/test/type_spec_annotation_test.dart
index f8f281ad1..2a87f2675 100644
--- a/dart/packages/fory/test/type_spec_annotation_test.dart
+++ b/dart/packages/fory/test/type_spec_annotation_test.dart
@@ -54,9 +54,7 @@ void main() {
   group('container type specs', () {
     test('ListType stores nested element specs', () {
       const lt = ListType(
-        element: MapType(
-          value: DeclaredType(ref: true),
-        ),
+        element: MapType(value: DeclaredType(ref: true)),
         nullable: true,
       );
       expect(lt.nullable, isTrue);
@@ -66,10 +64,7 @@ void main() {
     });
 
     test('ArrayType stores dense element specs', () {
-      const array = ArrayType(
-        element: Int32Type(),
-        nullable: true,
-      );
+      const array = ArrayType(element: Int32Type(), nullable: true);
       expect(array.nullable, isTrue);
       expect(array.element, isA<Int32Type>());
     });
@@ -78,10 +73,7 @@ void main() {
       const mt = MapType(
         key: StringType(),
         value: ListType(
-          element: Int32Type(
-            nullable: true,
-            encoding: Encoding.fixed,
-          ),
+          element: Int32Type(nullable: true, encoding: Encoding.fixed),
         ),
         ref: true,
       );
@@ -103,9 +95,7 @@ void main() {
         dynamic: true,
         type: MapType(
           key: StringType(),
-          value: ListType(
-            element: Int32Type(encoding: Encoding.fixed),
-          ),
+          value: ListType(element: Int32Type(encoding: Encoding.fixed)),
         ),
       );
       expect(field.id, equals(3));
@@ -114,17 +104,29 @@ void main() {
       expect(field.type, isA<MapType>());
     });
 
+    test('ForyField stores ignore without wire metadata', () {
+      const field = ForyField(ignore: true);
+      expect(field.ignore, isTrue);
+    });
+
+    test('ignored fields reject wire metadata', () {
+      final invalidFields = <ForyField Function()>[
+        () => ForyField(ignore: true, id: 1),
+        () => ForyField(ignore: true, nullable: true),
+        () => ForyField(ignore: true, ref: true),
+        () => ForyField(ignore: true, dynamic: true),
+        () => ForyField(ignore: true, type: StringType()),
+      ];
+      for (final invalidField in invalidFields) {
+        expect(invalidField, throwsA(isA<AssertionError>()));
+      }
+    });
+
     test('container sugar stores nested element and value overrides', () {
-      const listField = ListField(
-        element: DeclaredType(ref: true),
-      );
-      const arrayField = ArrayField(
-        element: Uint8Type(),
-      );
+      const listField = ListField(element: DeclaredType(ref: true));
+      const arrayField = ArrayField(element: Uint8Type());
       const mapField = MapField(
-        value: ListType(
-          element: Int32Type(encoding: Encoding.fixed),
-        ),
+        value: ListType(element: Int32Type(encoding: Encoding.fixed)),
       );
       expect((listField.element as DeclaredType).ref, isTrue);
       expect(arrayField.element, isA<Uint8Type>());
diff --git a/docs/guide/csharp/external-types.md b/docs/guide/csharp/external-types.md
index d9678d5d3..1af23e798 100644
--- a/docs/guide/csharp/external-types.md
+++ b/docs/guide/csharp/external-types.md
@@ -91,7 +91,8 @@ internal static class StatusSerializer
 
 The target enum's numeric values are authoritative. Do not copy its constants
 into the declaration. Every serialized numeric value must fit in the unsigned
-32-bit Fory enum tag range.
+32-bit Fory enum tag range. Cross-language peers must use matching explicit
+enum tags, such as Java's `@ForyEnumId`.
 
 ## Registration and Root Values
 
@@ -102,6 +103,7 @@ Fory fory = Fory.Builder().Build();
 fory.Register<ThirdParty.User>(100);
 fory.Register<ThirdParty.Status>("example.Status");
 
+ThirdParty.User user = new() { Name = "Alice", Age = 30 };
 byte[] bytes = fory.Serialize(user);
 ThirdParty.User decoded = fory.Deserialize<ThirdParty.User>(bytes);
 ```
@@ -128,6 +130,7 @@ public sealed class Group
 Root carrier composition also uses the ordinary typed API:
 
 ```csharp
+ThirdParty.User user = new() { Name = "Alice", Age = 30 };
 byte[] bytes = fory.Serialize(new List<ThirdParty.User> { user });
 
 List<ThirdParty.User> decoded =
@@ -151,7 +154,8 @@ typed roots.
 ## Declaration and Target Requirements
 
 An external structural serializer declaration must be a non-generic abstract
-class containing only abstract get-only schema properties. Each property:
+class containing only abstract get-only schema properties. Each serialized
+property:
 
 - matches an accessible target field or property with the same case-sensitive
   name;
@@ -165,13 +169,17 @@ nullability. Only members declared by the serializer are serialized; other
 target state keeps its normal default or derived behavior after
 deserialization.
 
+Use `[ForyField(Ignore = true)]` on an additional declaration property when
+non-serialized target storage still needs to contribute to the graph-memory
+estimate. Public target fields are included automatically.
+
 The target must be an accessible concrete class or struct with a legal
 parameterless construction path and writable state. Closed generic targets,
 such as `ThirdParty.Box<string>`, are supported; open generic targets are not.
 
-Targets that require private access, readonly or init-only assignment,
-constructor arguments, factories, member renaming, or value conversion require
-a [manual serializer](manual-serializers.md).
+Serialized members that require private access, readonly or init-only
+assignment, constructor arguments, factories, member renaming, or value
+conversion require a [manual serializer](manual-serializers.md).
 
 ## Schema Evolution
 
diff --git a/docs/guide/csharp/supported-types.md b/docs/guide/csharp/supported-types.md
index db6236377..1b1eccdf7 100644
--- a/docs/guide/csharp/supported-types.md
+++ b/docs/guide/csharp/supported-types.md
@@ -82,6 +82,9 @@ This page summarizes built-in and generated type support in Apache Fory™ C#.
 - Manual serializer types registered through `Register<T, TSerializer>(...)`
 - `Union` / `Union2<...>` typed union support
 
+`[ForyEnum]` numeric values are unsigned 32-bit wire tags and must be in the
+range `0..uint.MaxValue`.
+
 ## Dynamic Types
 
 Dynamic object payloads via `Serialize<object?>` / `Deserialize<object?>` support:
diff --git a/docs/guide/dart/external-types.md b/docs/guide/dart/external-types.md
index 0d9e96c6c..38314a33b 100644
--- a/docs/guide/dart/external-types.md
+++ b/docs/guide/dart/external-types.md
@@ -59,8 +59,12 @@ abstract final class UserSerializer {
 
 The declaration must be `abstract final`, cannot have type parameters, and
 must declare each schema field as `late final` without an initializer. Each
-field name and Dart type, including nullability and generic arguments, must
-exactly match an accessible getter on the target.
+serialized field name and Dart type, including nullability and generic
+arguments, must exactly match an accessible getter on the target.
+
+Public target fields count toward the graph-memory budget automatically. Use
+`@ForyField(ignore: true)` on an extra declaration field to count other storage
+without serializing it.
 
 Run the generator as usual:
 
diff --git a/docs/guide/dart/schema-metadata.md b/docs/guide/dart/schema-metadata.md
index 63d4da595..9d189ffa8 100644
--- a/docs/guide/dart/schema-metadata.md
+++ b/docs/guide/dart/schema-metadata.md
@@ -28,7 +28,7 @@ The same annotations apply to fields in an
 
 ```dart
 @ForyField(
-  skip: false,      // include the field; set true to exclude it
+  ignore: false,    // include the field; set true to exclude it
   id: 10,           // stable field ID for schema evolution
   nullable: true,   // override nullability detection
   ref: true,        // enable reference tracking for this field
@@ -36,12 +36,15 @@ The same annotations apply to fields in an
 )
 ```
 
-## `skip`
+## `ignore`
 
-Exclude a field from serialization entirely. Useful for cached, computed, or UI-only values that should not land in a persisted or transmitted message.
+Exclude a field from serialization entirely. Useful for cached, computed, or
+UI-only values that should not land in a persisted or transmitted message. The
+field still contributes to graph-memory accounting; because it has no wire
+representation, do not combine `ignore` with other `ForyField` options.
 
 ```dart
-@ForyField(skip: true)
+@ForyField(ignore: true)
 String cachedDisplayName = '';
 ```
 
diff --git a/docs/guide/swift/external-types.md b/docs/guide/swift/external-types.md
index 5307bf1f4..c1e8b8086 100644
--- a/docs/guide/swift/external-types.md
+++ b/docs/guide/swift/external-types.md
@@ -84,6 +84,10 @@ The target class must have an accessible zero-argument initializer and writable
 serialized properties. These requirements allow Fory to preserve shared and
 circular references.
 
+Swift budgets only fields listed in this declaration. Add
+`@ForyField(ignore: true)` fields for substantial omitted storage; they count
+toward the graph budget but are not serialized.
+
 Use a manual serializer if the class is immutable, requires constructor
 arguments, or cannot be safely observed before all fields are assigned.
 
diff --git a/docs/security/deserialization.md b/docs/security/deserialization.md
index 7f09f57a5..3afe08c52 100644
--- a/docs/security/deserialization.md
+++ b/docs/security/deserialization.md
@@ -287,6 +287,27 @@ type-erased materialization paths reserve the shallow storage for the heap value
 Parents must not recursively include child object, collection, map, string, binary, or primitive
 dense-array contents; the child owner reserves its own shallow memory when it is materialized.
 
+### External Structural Targets
+
+An external structural serializer does not own or necessarily expose the target type's complete
+storage layout. Its shallow graph-memory formula starts with every field declared by the external
+schema. A field marked `ignore` is a budget-only declaration: it contributes its declared storage
+width but must not enter wire metadata, generated reads or writes, target member access, or target
+construction.
+
+C# and Dart generators may additionally include public instance fields that are visible on the
+target at generation time. A public field represented by a schema declaration is counted once.
+Properties, accessors, interfaces, and runtime layout inspection must not be treated as additional
+stored fields. Swift macros cannot inspect another type's stored layout and therefore use only the
+external declaration.
+
+Other omitted target storage is outside this approximate formula. When it contains a large value
+field or enough fields to matter, the external declaration should list that storage and mark it
+ignored. The normal owner rules still apply: a reference target reserves its shallow owner and
+field storage, while an inline value target is charged by the holder that owns its storage. These
+formulas must be resolved during generation and must not add reflection, layout probing,
+allocation, or field enumeration to deserialization hot paths.
+
 ### Runtime-Specific Owner Notes
 
 #### C++
diff --git a/docs/specification/xlang_implementation_guide.md b/docs/specification/xlang_implementation_guide.md
index 7a7163734..85d2fd8f8 100644
--- a/docs/specification/xlang_implementation_guide.md
+++ b/docs/specification/xlang_implementation_guide.md
@@ -260,7 +260,13 @@ targets. Runtime type positions, construction, member access, `TypeInfo`,
 compatible metadata, graph-memory ownership, reference publication, generated
 factory keys, root lookup, and dynamic lookup use the target. The declaration
 supplies only field names, IDs, schema descriptors, nullability, and the
-structural `Evolving` setting.
+structural `Evolving` setting for wire fields. An ignored declaration field
+supplies only graph-memory storage and has no wire metadata or target access.
+For external classes, the shallow graph estimate includes declared wire
+fields, ignored declaration fields, and discoverable public instance target
+fields. An ignored declaration does not require its target storage to be
+visible to the generator. When it resolves to the same public field, the field
+symbol is used only to avoid counting that storage twice.
 
 Every generated ordinary or external struct uses
 `TypeResolver.RegisterGeneratedStruct<T, TSerializer>(bool evolving)` to carry
@@ -276,14 +282,15 @@ uses the resolver's target replacement rules.
 
 External structural targets require an accessible concrete class or struct,
 legal parameterless construction, and directly readable and assignable
-members. Schema properties match target fields or properties by case-sensitive
-name and exact CLR/generic type. Explicit nullability must match; when target
-metadata is nullable-oblivious, the declaration supplies schema nullability.
-Immutable, constructor-only, factory-only, init-only, readonly, renamed,
-converted, or inaccessible shapes use a manual `Serializer<T>`. Generated
-class reads allocate and publish the final target before recursive children;
-target structs are inline values. The declaration is never a temporary graph
-owner.
+wire members. Schema properties match target fields or properties by
+case-sensitive name and exact CLR/generic type. Explicit nullability must
+match; when target metadata is nullable-oblivious, the declaration supplies
+schema nullability. Ignored properties are declaration-owned budget hints and
+do not bind or access target members. Immutable, constructor-only,
+factory-only, init-only, readonly, renamed, converted, or inaccessible wire
+shapes use a manual `Serializer<T>`. Generated class reads allocate and
+publish the final target before recursive children; target structs are inline
+values. The declaration is never a temporary graph owner.
 
 C# carrier composition is target-based. The resolver recursively
 binds `Nullable<T>`, one-dimensional `T[]`, `List<T>`, `LinkedList<T>`,
@@ -575,6 +582,12 @@ manual serializer; the implementation must not use reflection, unsafe layout
 access, unavailable-overload tricks, schema mirror values, conversion wrappers,
 or builders as a fallback.
 
+Swift macros cannot inspect another type's stored layout. An external class's
+shallow graph-memory formula therefore uses its declaration fields only.
+`@ForyField(ignore: true)` adds a budget-only declaration field without schema,
+target access, construction, or wire code. Applications must use this form for
+substantial omitted storage.
+
 An external structural union requires the target to expose a lossless
 `unknown(UnknownCase)` case. A dependency-free target module may expose a
 generic unknown payload and let the application select its `UnknownCase`
@@ -1278,6 +1291,10 @@ In Java:
 - `@ForyEnumId` can override that with a stable explicit tag
 - `serializeEnumByName(true)` affects native Java mode, not xlang mode
 
+In C#, the enum's underlying numeric value is the xlang tag. Java peers for
+sparse C# enums must declare matching `@ForyEnumId` values instead of relying
+on declaration ordinals.
+
 Other language implementations should preserve the same wire rule even if the configuration or
 annotation surface differs.
 
@@ -1334,15 +1351,21 @@ name. Every runtime type position uses the target type: `Serializer<Target>`,
 `GeneratedStructSchema<Target>`, read and write signatures, constructor calls,
 schema `type`, and generated-module dispatch.
 
-For each declaration field, resolve an accessible target getter with the same
-name and exact instantiated Dart type. Constructor parameters and any
-post-construction setter must also match exactly. A public named generative
-constructor is selected only when the annotation names it. Factory
+For each serialized declaration field, resolve an accessible target getter
+with the same name and exact instantiated Dart type. Constructor parameters
+and any post-construction setter must also match exactly. A public named
+generative constructor is selected only when the annotation names it. Factory
 constructors, abstract targets, open target types, and constructor-based
 reference-tracked paths back to the target are rejected during generation.
 The recursive check includes target elements, keys, and values nested in the
 supported list, set, and map field metadata.
 
+An external object's shallow graph-memory formula is the union of declaration
+fields and public instance fields discovered on the target, its superclasses,
+and applied mixins. A public target field represented by a declaration is
+counted once. `@ForyField(ignore: true)` adds budget-only declaration storage
+without target access, construction, metadata, or wire code.
+
 Generated code reads getters and invokes target constructors or setters
 directly. It must not allocate the declaration, copy values through an
 intermediate object, invoke runtime callbacks, perform member-name lookup, or
diff --git a/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java b/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
index 95a94d061..78b3474be 100644
--- a/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
+++ b/java/fory-core/src/test/java/org/apache/fory/xlang/CSharpXlangTest.java
@@ -31,6 +31,7 @@ import java.util.Map;
 import java.util.concurrent.TimeUnit;
 import lombok.Data;
 import org.apache.fory.Fory;
+import org.apache.fory.annotation.ForyEnumId;
 import org.apache.fory.annotation.ForyField;
 import org.apache.fory.annotation.ForyStruct;
 import org.apache.fory.memory.MemoryBuffer;
@@ -495,8 +496,13 @@ public class CSharpXlangTest extends XlangTestBase {
   }
 
   enum CSharpExternalStatus {
+    @ForyEnumId(0)
     UNKNOWN,
+
+    @ForyEnumId(7)
     READY,
+
+    @ForyEnumId(23)
     DONE,
   }
 
diff --git a/swift/Sources/Fory/MacroDeclarations.swift b/swift/Sources/Fory/MacroDeclarations.swift
index bdba1db72..3f824e54a 100644
--- a/swift/Sources/Fory/MacroDeclarations.swift
+++ b/swift/Sources/Fory/MacroDeclarations.swift
@@ -242,9 +242,11 @@ public struct ForyFieldType: Sendable {
 }
 
 @attached(peer)
-/// Configures a generated field and optionally selects its exact serializer.
+/// Configures a generated field, selects its exact serializer, or declares
+/// budget-only storage for an external structural serializer.
 public macro ForyField(
     id: Int? = nil,
+    ignore: Bool = false,
     encoding: ForyFieldEncoding? = nil,
     type: ForyFieldType? = nil,
     with serializer: Any.Type? = nil
diff --git a/swift/Sources/ForyMacro/ForyObjectMacro.swift b/swift/Sources/ForyMacro/ForyObjectMacro.swift
index 3797ec6da..064dea84d 100644
--- a/swift/Sources/ForyMacro/ForyObjectMacro.swift
+++ b/swift/Sources/ForyMacro/ForyObjectMacro.swift
@@ -52,7 +52,10 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
             throw MacroExpansionErrorMessage("@ForyStruct supports struct and class declarations only")
         }
 
-        let parsed = try parseFields(declaration)
+        let parsed = try parseFields(
+            declaration,
+            isExternal: objectConfig.targetType != nil
+        )
         let sortedFields = sortFields(parsed.fields)
         let targetType =
             objectConfig.targetType
@@ -98,6 +101,7 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
             stringLiteral: buildDefaultDecl(
                 isClass: parsed.isClass,
                 fields: parsed.fields,
+                graphFields: parsed.graphFields,
                 accessPrefix: accessPrefix
             )
         )
@@ -125,8 +129,7 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
         )
         let readDecl: DeclSyntax = DeclSyntax(
             stringLiteral: buildReadDataDecl(
-                isClass: parsed.isClass,
-                fields: parsed.fields,
+                declaration: parsed,
                 sortedFields: sortedFields,
                 accessPrefix: accessPrefix,
                 successBodyAttribute: successBodyAttribute
@@ -134,8 +137,7 @@ public struct ForyStructMacro: MemberMacro, ExtensionMacro {
         )
         let readCompatibleDecl: DeclSyntax = DeclSyntax(
             stringLiteral: buildReadCompatibleDataDecl(
-                isClass: parsed.isClass,
-                fields: parsed.fields,
+                declaration: parsed,
                 sortedFields: sortedFields,
                 accessPrefix: accessPrefix
             )
@@ -368,9 +370,17 @@ struct ParsedField {
     let customCodecType: String?
 }
 
-private struct ParsedDecl {
+enum ParsedGraphField {
+    case serialized(ParsedField)
+    // An ignored external field declares storage only; retaining just its type
+    // keeps it out of schema, target access, and construction generation.
+    case ignored(typeText: String)
+}
+
+struct ParsedDecl {
     let isClass: Bool
     let fields: [ParsedField]
+    let graphFields: [ParsedGraphField]
 }
 
 private enum ParsedEnumKind: Equatable {
@@ -417,6 +427,7 @@ private indirect enum FieldTypeHint {
 private struct ParsedForyFieldConfiguration {
     let encoding: FieldEncoding?
     let id: Int?
+    let ignore: Bool?
     let typeHint: FieldTypeHint?
     let serializerType: String?
 }
@@ -941,13 +952,17 @@ private func enumCaseDefaultExpr(_ enumCase: ParsedEnumCase) -> String {
     return ".\(enumCase.name)(\(args))"
 }
 
-private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDecl {
+private func parseFields(
+    _ declaration: some DeclGroupSyntax,
+    isExternal: Bool
+) throws -> ParsedDecl {
     let isClass = declaration.is(ClassDeclSyntax.self)
     guard isClass || declaration.is(StructDeclSyntax.self) else {
         throw MacroExpansionErrorMessage("@ForyStruct supports struct and class only")
     }
 
     var fields: [ParsedField] = []
+    var graphFields: [ParsedGraphField] = []
     var originalIndex = 0
 
     for member in declaration.memberBlock.members {
@@ -955,14 +970,12 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
             continue
         }
 
-        if varDecl.modifiers.contains(where: { $0.name.tokenKind == .keyword(.static) || $0.name.tokenKind == .keyword(.class) }) {
-            continue
-        }
-
         let fieldConfig = try parseForyFieldConfiguration(
             from: varDecl.attributes,
             supportsEncoding: true
         )
+        let ignoreSpecified = fieldConfig?.ignore != nil
+        let isIgnored = fieldConfig?.ignore == true
         let directTypeHint =
             fieldConfig?.serializerType.map { FieldTypeHint.with(serializerType: $0) }
             ?? fieldConfig?.typeHint
@@ -970,15 +983,49 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
             from: varDecl.attributes,
             existing: directTypeHint
         )
+        if ignoreSpecified && !isExternal {
+            throw MacroExpansionErrorMessage(
+                "@ForyField(ignore:) is only supported by external @ForyStruct declarations"
+            )
+        }
+        if isIgnored,
+            fieldConfig?.id != nil || fieldConfig?.encoding != nil || fieldTypeHint != nil
+        {
+            throw MacroExpansionErrorMessage(
+                "@ForyField(ignore: true) cannot be combined with wire or nested field options"
+            )
+        }
+        let isStatic = varDecl.modifiers.contains {
+            $0.name.tokenKind == .keyword(.static)
+                || $0.name.tokenKind == .keyword(.class)
+        }
+        if ignoreSpecified && isStatic {
+            throw MacroExpansionErrorMessage(
+                "@ForyField(ignore:) requires a named instance stored property"
+            )
+        }
+        if isStatic {
+            continue
+        }
         if fieldConfig != nil || fieldTypeHint != nil, varDecl.bindings.count != 1 {
             throw MacroExpansionErrorMessage("Fory field annotations can only be used on a single stored property")
         }
 
         for binding in varDecl.bindings {
             guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
+                if ignoreSpecified {
+                    throw MacroExpansionErrorMessage(
+                        "@ForyField(ignore:) requires a named instance stored property"
+                    )
+                }
                 continue
             }
-            guard binding.accessorBlock == nil else {
+            if binding.accessorBlock != nil {
+                if ignoreSpecified {
+                    throw MacroExpansionErrorMessage(
+                        "@ForyField(ignore:) requires a named instance stored property"
+                    )
+                }
                 continue
             }
             guard let typeAnnotation = binding.typeAnnotation else {
@@ -987,6 +1034,11 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
 
             let name = pattern.identifier.text
             let rawType = typeAnnotation.type.trimmedDescription
+            if isIgnored {
+                graphFields.append(.ignored(typeText: rawType))
+                originalIndex += 1
+                continue
+            }
             let optionalUnwrapped = unwrapOptional(rawType)
             let isOptional = optionalUnwrapped.isOptional
             let concreteType = optionalUnwrapped.type
@@ -1021,25 +1073,25 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
                 group = 3
             }
 
-            fields.append(
-                ParsedField(
-                    name: name,
-                    typeText: rawType,
-                    typeHint: fieldTypeHint,
-                    originalIndex: originalIndex,
-                    isOptional: isOptional,
-                    isCollection: classification.isCollection || classification.isMap,
-                    fieldID: fieldID,
-                    schemaIdentifier: schemaIdentifier,
-                    fieldIdentifier: fieldIdentifier,
-                    group: group,
-                    typeID: classification.typeID,
-                    isCompressedNumeric: classification.isCompressedNumeric,
-                    primitiveSize: classification.primitiveSize,
-                    customCodecType:
-                        typeResolution.customCodecType ?? dynamicCodecType ?? carrierCodecType
-                )
+            let field = ParsedField(
+                name: name,
+                typeText: rawType,
+                typeHint: fieldTypeHint,
+                originalIndex: originalIndex,
+                isOptional: isOptional,
+                isCollection: classification.isCollection || classification.isMap,
+                fieldID: fieldID,
+                schemaIdentifier: schemaIdentifier,
+                fieldIdentifier: fieldIdentifier,
+                group: group,
+                typeID: classification.typeID,
+                isCompressedNumeric: classification.isCompressedNumeric,
+                primitiveSize: classification.primitiveSize,
+                customCodecType:
+                    typeResolution.customCodecType ?? dynamicCodecType ?? carrierCodecType
             )
+            fields.append(field)
+            graphFields.append(.serialized(field))
             originalIndex += 1
         }
     }
@@ -1057,7 +1109,11 @@ private func parseFields(_ declaration: some DeclGroupSyntax) throws -> ParsedDe
         seenFieldIDs[fieldID] = field.name
     }
 
-    return ParsedDecl(isClass: isClass, fields: fields)
+    return ParsedDecl(
+        isClass: isClass,
+        fields: fields,
+        graphFields: graphFields
+    )
 }
 
 private func parseForyFieldConfiguration(
@@ -1066,6 +1122,7 @@ private func parseForyFieldConfiguration(
 ) throws -> ParsedForyFieldConfiguration? {
     var parsedEncoding: FieldEncoding?
     var parsedID: Int?
+    var parsedIgnore: Bool?
     var parsedTypeHint: FieldTypeHint?
     var parsedSerializerType: String?
     for element in attributes {
@@ -1111,6 +1168,20 @@ private func parseForyFieldConfiguration(
                 continue
             }
 
+            if label == "ignore" {
+                let ignore = try parseBoolLiteralExpression(
+                    arg.expression,
+                    message: "@ForyField ignore must be a boolean literal"
+                )
+                if let existing = parsedIgnore, existing != ignore {
+                    throw MacroExpansionErrorMessage(
+                        "conflicting @ForyField ignore values on the same declaration"
+                    )
+                }
+                parsedIgnore = ignore
+                continue
+            }
+
             if label == "type" {
                 if parsedTypeHint != nil {
                     throw MacroExpansionErrorMessage("conflicting @ForyField type hints on the same declaration")
@@ -1131,7 +1202,7 @@ private func parseForyFieldConfiguration(
             }
 
             throw MacroExpansionErrorMessage(
-                "@ForyField supports only 'id', 'encoding', 'type', and 'with' arguments"
+                "@ForyField supports only 'id', 'ignore', 'encoding', 'type', and 'with' arguments"
             )
         }
     }
@@ -1147,13 +1218,19 @@ private func parseForyFieldConfiguration(
         )
     }
 
-    if parsedEncoding == nil, parsedID == nil, parsedTypeHint == nil, parsedSerializerType == nil {
+    if parsedEncoding == nil,
+        parsedID == nil,
+        parsedIgnore == nil,
+        parsedTypeHint == nil,
+        parsedSerializerType == nil
+    {
         return nil
     }
 
     return ParsedForyFieldConfiguration(
         encoding: parsedEncoding,
         id: parsedID,
+        ignore: parsedIgnore,
         typeHint: parsedTypeHint,
         serializerType: parsedSerializerType
     )
@@ -2758,11 +2835,16 @@ private func buildSchemaTypeFingerprint(
     return "\"\\(__foryNormalizeSchemaFingerprintTypeID(\(typeIDExpr))),\\(\(trackFlagExpr)),\(nullableLiteral)\""
 }
 
-private func buildDefaultDecl(isClass: Bool, fields: [ParsedField], accessPrefix: String) -> String {
+private func buildDefaultDecl(
+    isClass: Bool,
+    fields: [ParsedField],
+    graphFields: [ParsedGraphField],
+    accessPrefix: String
+) -> String {
     if isClass {
         return """
             \(accessPrefix)static func defaultValue(_ context: ReadContext) throws -> Target {
-                try context.reserveGraphMemory(\(classGraphOwnerBytesExpr(fields)))
+                try context.reserveGraphMemory(\(classGraphOwnerBytesExpr(graphFields)))
                 return Target.init()
             }
             """
diff --git a/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift b/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift
index f592f810e..f533ae257 100644
--- a/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift
+++ b/swift/Sources/ForyMacro/ForyObjectMacroReadGeneration.swift
@@ -16,27 +16,27 @@
 // under the License.
 
 func buildReadDataDecl(
-    isClass: Bool,
-    fields: [ParsedField],
+    declaration: ParsedDecl,
     sortedFields: [ParsedField],
     accessPrefix: String,
     successBodyAttribute: String
 ) -> String {
-    if isClass {
+    if declaration.isClass {
         return buildClassReadDataDecl(
             sortedFields: sortedFields,
+            graphFields: declaration.graphFields,
             accessPrefix: accessPrefix,
             successBodyAttribute: successBodyAttribute
         )
     }
-    if fields.isEmpty {
+    if declaration.fields.isEmpty {
         return buildEmptyStructReadDataDecl(
             accessPrefix: accessPrefix,
             successBodyAttribute: successBodyAttribute
         )
     }
     return buildStructReadDataDecl(
-        fields: fields,
+        fields: declaration.fields,
         sortedFields: sortedFields,
         accessPrefix: accessPrefix,
         successBodyAttribute: successBodyAttribute
@@ -44,22 +44,28 @@ func buildReadDataDecl(
 }
 
 func buildReadCompatibleDataDecl(
-    isClass: Bool,
-    fields: [ParsedField],
+    declaration: ParsedDecl,
     sortedFields: [ParsedField],
     accessPrefix: String
 ) -> String {
-    if isClass {
-        return buildClassReadCompatibleDataDecl(sortedFields: sortedFields, accessPrefix: accessPrefix)
+    if declaration.isClass {
+        return buildClassReadCompatibleDataDecl(
+            sortedFields: sortedFields,
+            graphFields: declaration.graphFields,
+            accessPrefix: accessPrefix
+        )
     }
-    if fields.isEmpty {
+    if declaration.fields.isEmpty {
         return buildEmptyStructReadCompatibleDataDecl(accessPrefix: accessPrefix)
     }
     return buildStructReadCompatibleDataDecl(
-        fields: fields, sortedFields: sortedFields, accessPrefix: accessPrefix)
+        fields: declaration.fields,
+        sortedFields: sortedFields,
+        accessPrefix: accessPrefix
+    )
 }
 
-private func graphFieldBytesExpr(_ field: ParsedField) -> String {
+private func serializedGraphFieldBytesExpr(_ field: ParsedField) -> String {
     if field.primitiveSize > 0 {
         return "\(field.primitiveSize)"
     }
@@ -73,7 +79,16 @@ private func graphFieldBytesExpr(_ field: ParsedField) -> String {
     return "(\(field.typeText).isRefType ? 4 : max(1, MemoryLayout<\(field.typeText)>.stride))"
 }
 
-func classGraphOwnerBytesExpr(_ fields: [ParsedField]) -> String {
+private func graphFieldBytesExpr(_ field: ParsedGraphField) -> String {
+    switch field {
+    case .serialized(let serializedField):
+        return serializedGraphFieldBytesExpr(serializedField)
+    case .ignored(let typeText):
+        return "max(1, MemoryLayout<\(typeText)>.stride)"
+    }
+}
+
+func classGraphOwnerBytesExpr(_ fields: [ParsedGraphField]) -> String {
     let ownerBytes = "(2 * MemoryLayout<Int>.stride)"
     if fields.isEmpty {
         return ownerBytes
@@ -81,7 +96,10 @@ func classGraphOwnerBytesExpr(_ fields: [ParsedField]) -> String {
     return ownerBytes + " + " + fields.map(graphFieldBytesExpr).joined(separator: " + ")
 }
 
-private func reserveClassGraphOwnerLine(fields: [ParsedField], indent: String) -> String {
+private func reserveClassGraphOwnerLine(
+    fields: [ParsedGraphField],
+    indent: String
+) -> String {
     "\(indent)try context.reserveGraphMemory(\(classGraphOwnerBytesExpr(fields)))"
 }
 
@@ -216,6 +234,7 @@ func buildStructReadWrapperDecl(
 
 private func buildClassReadDataDecl(
     sortedFields: [ParsedField],
+    graphFields: [ParsedGraphField],
     accessPrefix: String,
     successBodyAttribute: String
 ) -> String {
@@ -228,7 +247,7 @@ private func buildClassReadDataDecl(
         private static func __foryReadDataImpl(_ context: ReadContext, reservedRefID: UInt32?) throws -> Target {
             let __buffer = context.buffer
             \(schemaHashCheckExpr())
-            \(reserveClassGraphOwnerLine(fields: sortedFields, indent: "        "))
+            \(reserveClassGraphOwnerLine(fields: graphFields, indent: "        "))
             let value = Target.init()
             if let reservedRefID {
                 context.refReader.storeRef(value, at: reservedRefID)
@@ -287,6 +306,7 @@ private func buildStructReadDataDecl(
 
 private func buildClassReadCompatibleDataDecl(
     sortedFields: [ParsedField],
+    graphFields: [ParsedGraphField],
     accessPrefix: String
 ) -> String {
     let primitiveFastFields = leadingPrimitiveFastPathFields(sortedFields)
@@ -320,7 +340,7 @@ private func buildClassReadCompatibleDataDecl(
             \(bufferBinding)guard let typeMeta = remoteTypeInfo.compatibleTypeMeta else {
                 throw ForyError.invalidData("compatible type metadata is required")
             }
-            \(reserveClassGraphOwnerLine(fields: sortedFields, indent: "        "))
+            \(reserveClassGraphOwnerLine(fields: graphFields, indent: "        "))
             let value = Target.init()
             if let reservedRefID {
                 context.refReader.storeRef(value, at: reservedRefID)
diff --git a/swift/Tests/ForyExternalModels/ExternalModels.swift b/swift/Tests/ForyExternalModels/ExternalModels.swift
index e60e7f3d5..a7df43dbe 100644
--- a/swift/Tests/ForyExternalModels/ExternalModels.swift
+++ b/swift/Tests/ForyExternalModels/ExternalModels.swift
@@ -95,6 +95,7 @@ public struct Group: Equatable {
 public final class Node {
     public var value: Int32 = 0
     public var next: Node?
+    private var omittedState: (UInt64, UInt64) = (0, 0)
 
     public init() {}
 }
diff --git a/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift b/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift
index 2732c0858..0bfb347ce 100644
--- a/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift
+++ b/swift/Tests/ForyTests/ExternalTypeSerializationTests.swift
@@ -117,10 +117,19 @@ private struct CarrierSelections: Equatable {
 
 @ForyStruct(target: Node.self)
 private final class NodeSerializer {
+    @ForyField(ignore: false)
     var value: Int32 = 0
 
     @ForyField(with: OptionalSerializer<NodeSerializer>.self)
     var next: Node?
+
+    @ForyField(ignore: true)
+    var omittedState: (UInt64, UInt64) = (0, 0)
+}
+
+@ForyStruct(target: Node.self)
+private final class NodeValueSerializer {
+    var value: Int32 = 0
 }
 
 @ForyEnum(target: Status.self)
@@ -482,6 +491,64 @@ func externalClassCycleRoundTrip() throws {
     #expect(decoded.next === decoded)
 }
 
+@Test
+func externalIgnoredFieldBudget() throws {
+    let value = Node()
+    value.value = 42
+    let required =
+        2 * MemoryLayout<Int>.stride
+        + MemoryLayout<Int32>.stride
+        + 4
+        + MemoryLayout<(UInt64, UInt64)>.stride
+
+    for compatible in [false, true] {
+        let writer = Fory(
+            config: .init(
+                trackRef: false,
+                compatible: compatible
+            )
+        )
+        let bytes: Data
+        if compatible {
+            try writer.register(NodeValueSerializer.self, id: 142)
+            bytes = try writer.serialize(value, with: NodeValueSerializer.self)
+        } else {
+            try writer.register(NodeSerializer.self, id: 142)
+            bytes = try writer.serialize(value, with: NodeSerializer.self)
+        }
+
+        let limited = Fory(
+            config: .init(
+                trackRef: false,
+                compatible: compatible,
+                maxGraphMemoryBytes: Int64(required - 1)
+            )
+        )
+        try limited.register(NodeSerializer.self, id: 142)
+        #expect(throws: ForyError.self) {
+            let _: Node = try limited.deserialize(
+                bytes,
+                with: NodeSerializer.self
+            )
+        }
+
+        let exact = Fory(
+            config: .init(
+                trackRef: false,
+                compatible: compatible,
+                maxGraphMemoryBytes: Int64(required)
+            )
+        )
+        try exact.register(NodeSerializer.self, id: 142)
+        let decoded: Node = try exact.deserialize(
+            bytes,
+            with: NodeSerializer.self
+        )
+        #expect(decoded.value == value.value)
+        #expect(decoded.next == nil)
+    }
+}
+
 @Test
 func externalEnumAndUnionRoundTrip() throws {
     let fory = Fory(config: .init(trackRef: false, compatible: false))
diff --git a/swift/Tests/ForyTests/MacroDiagnosticTests.swift b/swift/Tests/ForyTests/MacroDiagnosticTests.swift
index 1e1d7d50b..2b6bf5ef5 100644
--- a/swift/Tests/ForyTests/MacroDiagnosticTests.swift
+++ b/swift/Tests/ForyTests/MacroDiagnosticTests.swift
@@ -395,3 +395,176 @@ func serializerExistentialRejected() {
             "fields cannot use 'any Serializer' as an application value; select a concrete application protocol with DynamicSerializer"
     )
 }
+
+@Test
+func ignoredFieldRequiresExternalTarget() {
+    assertForyDiagnostic(
+        """
+        @ForyStruct
+        struct BadIgnoredField {
+            @ForyField(ignore: true)
+            var value: Int32 = 0
+        }
+        """,
+        expandedSource:
+            """
+                struct BadIgnoredField {
+                    var value: Int32 = 0
+                }
+            """,
+        message: "@ForyField(ignore:) is only supported by external @ForyStruct declarations"
+    )
+
+    assertForyDiagnostic(
+        """
+        @ForyStruct
+        struct BadExplicitFalse {
+            @ForyField(ignore: false)
+            var value: Int32 = 0
+        }
+        """,
+        expandedSource:
+            """
+            struct BadExplicitFalse {
+                var value: Int32 = 0
+            }
+            """,
+        message: "@ForyField(ignore:) is only supported by external @ForyStruct declarations"
+    )
+}
+
+@Test
+func ignoredFieldRequiresLiteral() {
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        struct BadIgnoredField {
+            @ForyField(ignore: enabled)
+            var value: Int32 = 0
+        }
+        """,
+        expandedSource:
+            """
+            struct BadIgnoredField {
+                var value: Int32 = 0
+            }
+            """,
+        message: "@ForyField ignore must be a boolean literal"
+    )
+}
+
+@Test
+func ignoredFieldRejectsWireOptions() {
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        struct BadIgnoredField {
+            @ForyField(id: 1, ignore: true)
+            var value: Int32 = 0
+        }
+        """,
+        expandedSource:
+            """
+            struct BadIgnoredField {
+                var value: Int32 = 0
+            }
+            """,
+        message: "@ForyField(ignore: true) cannot be combined with wire or nested field options"
+    )
+}
+
+@Test
+func ignoredFieldRejectsNestedOptions() {
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        struct BadIgnoredField {
+            @ForyField(ignore: true)
+            @ListField(element: .int32())
+            var value: [Int32] = []
+        }
+        """,
+        expandedSource:
+            """
+            struct BadIgnoredField {
+                var value: [Int32] = []
+            }
+            """,
+        message: "@ForyField(ignore: true) cannot be combined with wire or nested field options"
+    )
+}
+
+@Test
+func ignoredFieldShapeDiagnostics() {
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        final class BadStaticField {
+            @ForyField(ignore: true)
+            static var value: Int32 = 0
+        }
+        """,
+        expandedSource:
+            """
+            final class BadStaticField {
+                static var value: Int32 = 0
+            }
+            """,
+        message: "@ForyField(ignore:) requires a named instance stored property"
+    )
+
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        struct BadPatternField {
+            @ForyField(ignore: true)
+            var (first, second): (Int32, Int32) = (0, 0)
+        }
+        """,
+        expandedSource:
+            """
+            struct BadPatternField {
+                var (first, second): (Int32, Int32) = (0, 0)
+            }
+            """,
+        message: "@ForyField(ignore:) requires a named instance stored property"
+    )
+
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        final class BadComputedField {
+            @ForyField(ignore: true)
+            var value: Int32 {
+                0
+            }
+        }
+        """,
+        expandedSource:
+            """
+            final class BadComputedField {
+                var value: Int32 {
+                    0
+                }
+            }
+            """,
+        message: "@ForyField(ignore:) requires a named instance stored property"
+    )
+
+    assertForyDiagnostic(
+        """
+        @ForyStruct(target: External.self)
+        struct BadMultiBinding {
+            @ForyField(ignore: true)
+            var first: Int32 = 0, second: Int32 = 0
+        }
+        """,
+        expandedSource:
+            """
+            struct BadMultiBinding {
+                var first: Int32 = 0, second: Int32 = 0
+            }
+            """,
+        message: "Fory field annotations can only be used on a single stored property"
+    )
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@fory.apache.org
For additional commands, e-mail: commits-help@fory.apache.org