Skip to content

perf(python): read union case fields directly instead of rebuilding Array - #4725

Merged
MangelMaxime merged 2 commits into
mainfrom
perf/python-read-union-cases
Jul 8, 2026
Merged

MangelMaxime merged 2 commits into
mainfrom
perf/python-read-union-cases

Conversation

@MangelMaxime

Copy link
Copy Markdown
Member

Summary

When working on Scriptorium, I discovered that Python generated code is slower than JS for union-heavy workloads.

One of the main reasons is that union case field reads (x.fields[i], emitted for every pattern match) went through Union.fields, a @property that rebuilds a full Array from getattr() calls on every access. Even if JavaScript does a similar thing, in that case it stores fields once at construction, while Python rebuilds it every time.

In my codebase, it resulted in union.fields being called (~8M calls) when profiling.

Amounting to ~5sec of the run time.

Before:

def sum(t: Tree) -> int32:
    match t.tag:
        case 1:
            return sum(t.fields[0]) + sum(t.fields[1])
        case _:
            return t.fields[0]

After:

def sum(t: Tree) -> int32:
    match t.tag:
        case 1:
            return sum(cast(Tree_Node, t).left_) + sum(cast(Tree_Node, t).right_)
        case _:
            return cast(Tree_Leaf, t).value_

The drawback I found from this change is that we now need to cast the union to the case class type before reading its fields. Otherwise, Pyright will complain that the "field property" (ex left_, item1, etc. ) is not defined on the union type.

Benchmark

I made a small benchmark to test the performance difference between the two approaches using something like the following code:

type Tree =
    | Leaf of value: int
    | Node of left: Tree * right: Tree

let rec sum (t: Tree) : int =
    match t with
    | Leaf v -> v
    | Node (l, r) -> sum l + sum r

Results:

nodes before after speedup
2,047 2.24s 0.22s 10.2x
65,535 3.86s 0.56s 6.8x
2,097,151 4.51s 0.70s 6.4x

@MangelMaxime
MangelMaxime requested a review from dbrattli July 6, 2026 13:41
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Python Type Checking Results (Pyright)

Metric Value
Total errors 34
Files with errors 4
Excluded files 4
New errors ✅ No
Excluded files with errors (4 files)

These files have known type errors and are excluded from CI. Remove from pyrightconfig.ci.json as errors are fixed.

File Errors Status
temp/tests/Python/test_hash_set.py 18 Excluded
temp/tests/Python/test_applicative.py 12 Excluded
temp/tests/Python/test_nested_and_recursive_pattern.py 2 Excluded
temp/tests/Python/fable_modules/thoth_json_python/encode.py 2 Excluded

@dbrattli

dbrattli commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

Review

Nice find and a solid, well-motivated fix — reading the named backing field directly instead of rebuilding an Array through the .fields property on every access. I verified the core invariants and they hold up:

  • Field-name consistency — the read path computes field.Name |> Naming.toFieldSnakeCase |> Helpers.clean, byte-for-byte identical to how the case class fields are generated, so the attribute name always matches. ✅
  • Scope is safe — Fable.UnionField is only produced for genuine DiscriminatedUnions (FSharp2Fable.fs); erased/StringEnum/Option/List/TS-tagged unions route through other Get kinds, so com.GetEntity(i.Entity) always resolves a real DU with case classes. ✅
  • No expression duplication — baseExpr is transformed once and reused. ✅
  • Library unions (Result/Choice) resolve correctly because their case classes are themselves Fable-generated with the same field naming (ResultValue → result_value, unnamed Item → item). ✅
  • Extracting getUnionCaseRef is a good DRY move — construction and reads now share one source of truth for resolving the case class.

Notes / risks

  • New cross-module import edge (low risk, guarded). Reading a field from a union defined in another module and only consumed here now imports the case class (the old x.fields[i] needed no import). This is a genuinely new dependency edge that could in principle introduce an import cycle. Mitigated by reportImportCycles: true, and cross-module construction already created the same edge.
  • Test coverage. Worth confirming the existing suite exercises (a) a cross-module union that is pattern-matched but not constructed locally (the new import edge) and (b) reflection / .fields-dependent behavior (%A, equality) still works, since .fields is intentionally retained.
  • Typos in the pyrightconfig comment: addional → additional, Pyrigh → Pyright, resolved typed → resolved type.

On reportUnnecessaryCast

I'd like to resolve the global disabling of reportUnnecessaryCast before this merges. It relaxes strictness across all generated Python, not just union field reads, so genuinely-redundant casts elsewhere would no longer be caught — I'd rather not lose that coverage.

I've opened #4731 (targeting this branch) with the approach I'd like to take here: move the cast into a small narrow(Case, x) helper in union.py — a typed identity function that plays the same role as typing.cast but isn't typing.cast, so reportUnnecessaryCast stays enabled. Generated code becomes narrow(Tree_Node, t).left_ instead of cast(...), runtime cost is identical (single identity call), and the config change is reverted. Let's fold #4731 in as part of this work.

Overall the core change is sound — correctness is airtight because construction and reads share the same field-naming computation. Happy to get this in once #4731 is incorporated.

…typing.cast` (#4731)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MangelMaxime
MangelMaxime merged commit e4eaa80 into main Jul 8, 2026
24 checks passed
@MangelMaxime
MangelMaxime deleted the perf/python-read-union-cases branch July 8, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants