Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

krodak
Copy link
Contributor

@krodak krodak commented Aug 27, 2025

Introduction

This PR support for enums with associated values of primitive types to the BridgeJS plugin.

Design Overview

I've decided to go with DataView buffer to encode short type information and then payload, to pass single value across the boundaries and unpack it on the other side.

I started with single value primitives using what is currently available, then migrated to serializing parameters into JSON string and with current solution

Other option I've considered but rejected was to generate a separate WASM export function for each enum case - this eliminates the need for case dispatching and parameter packing, but on the other hand it would lead to WASM export explosion given that each case in each enum would end up as separate function and multiple associated values within single case would lead to unreadable long parameters chains.

Alternatives

As discussed, other alternative would be to allocate value types on Swift side, box it and use handle + accessors for access on JS side similarly to classes.
But as for this, we should probably decide on high level how we want to treat value types and whether we want to focus on just one of those options or use them depending on type, amount of properties or estimated payload size.

Examples

Case Enum with Both Styles

@JS enum APIResult {
    case success(String)
    case failure(Int)
    case flag(Bool)
    case rate(Float)
    case precise(Double)
    case info
}
@JS enum ComplexResult {
    case success(String)
    case error(String, Int)
    case status(Bool, Int, String)
    case coordinates(Double, Double, Double)
    case comprehensive(Bool, Bool, Int, Int, Double, Double, String, String, String)
    case info
}

Generated TypeScript:

export const APIResult: {
    readonly Tag: {
        readonly Success: 0;
        readonly Failure: 1;
        readonly Flag: 2;
        readonly Rate: 3;
        readonly Precise: 4;
        readonly Info: 5;
    };
};

export type APIResult =
  { tag: typeof APIResult.Tag.Success; param0: string } | { tag: typeof APIResult.Tag.Failure; param0: number } | { tag: typeof APIResult.Tag.Flag; param0: boolean } | { tag: typeof APIResult.Tag.Rate; param0: number } | { tag: typeof APIResult.Tag.Precise; param0: number } | { tag: typeof APIResult.Tag.Info }

export const ComplexResult: {
    readonly Tag: {
        readonly Success: 0;
        readonly Error: 1;
        readonly Status: 2;
        readonly Coordinates: 3;
        readonly Comprehensive: 4;
        readonly Info: 5;
    };
};

export type ComplexResult =
  { tag: typeof ComplexResult.Tag.Success; param0: string } | { tag: typeof ComplexResult.Tag.Error; param0: string; param1: number } | { tag: typeof ComplexResult.Tag.Status; param0: boolean; param1: number; param2: string } | { tag: typeof ComplexResult.Tag.Coordinates; param0: number; param1: number; param2: number } | { tag: typeof ComplexResult.Tag.Comprehensive; param0: boolean; param1: boolean; param2: number; param3: number; param4: number; param5: number; param6: string; param7: string; param8: string } | { tag: typeof ComplexResult.Tag.Info }

Testing

Added tests with different associated values, testing multiple properties of same type (as this is new addition) and working functions for nested enums based on last fix

Docs

Extended enum section in newly adjusted documentation

@krodak krodak requested a review from kateinoigakukun August 27, 2025 16:35
@krodak krodak self-assigned this Aug 27, 2025
@krodak krodak force-pushed the feat/enum-associated-values-primitive branch from d3a5164 to 516e46b Compare August 27, 2025 16:52
@krodak krodak linked an issue Aug 28, 2025 that may be closed by this pull request
@kateinoigakukun
Copy link
Member

Sorry for taking a long time 🙇
I ran some experiments to compare ABI designs for passing/returning composition types, including enum and struct.
Here’s a summary of three approaches, their trade-offs, and representative benchmark results.

Option 1: Memory-encoding ABI (current in this PR)

Encode each parameter into a byte buffer on the JS side, copy into Wasm memory, and decode on the Swift side.

I think it's very straightforward but I see some drawbacks:

  • Needs a custom memory layout definition for each primitive.
    • We already have 4 ABI definitions for each type: lifting parameter, lowering result, lowering parameter, lifting result
    • Adding a new memory layout definition for each type is a bit concerning, as it increases the implementation cost to add a new type support.
  • Multiple copies (encode to Uint8Array -> copy into Swift [UInt8] -> decode).
  • Benchmarks: consistently slower.

I think we have some perf optimization space here but my major concern is its implementation complexity

Option 2: Stack-based ABI

Push Wasm core values of lowered parameter representation onto a temporary JS stack, pop them from Swift and lift them by reusing bridgeJSLiftParameter.

Similar to what you've done for result lifting here. We can apply it to both parameter passing and result returning.

lower: (value) => {
    const enumTag = value.tag;
    switch (enumTag) {
        case APIResult.Tag.Success: {
            const bytes = textEncoder.encode(value.param0);
            const bytesId = swift.memory.retain(bytes);
            tmpParamI32s.push(bytes.byteLength);
            tmpParamI32s.push(bytesId);
            return;
        }
        case APIResult.Tag.Failure: {
            tmpParamI32s.push(value.param0);
            return;
        }
        case APIResult.Tag.Flag: {
            tmpParamI32s.push(value.param0 ? 1 : 0);
            return;
        }
        case APIResult.Tag.Rate: {
            tmpParamF64s.push(value.param0);
            return;
        }
        case APIResult.Tag.Precise: {
            tmpParamF64s.push(value.param0);
            return;
        }
        case APIResult.Tag.Info: {
            return;
        }
        default: throw new Error("Unknown APIResult tag: " + String(enumTag));
    }
},

addImports: (importObject, importsContext) => {
    ...
    bjs["swift_js_pop_param_int32"] = function() {
        return tmpParamI32s.pop();
    }
    bjs["swift_js_pop_param_float64"] = function() {
        return tmpParamF64s.pop();
    }
}
private extension APIResult {
    static func bridgeJSLiftParameter(_ caseId: Int32) -> APIResult {
        switch caseId {
        case 0:
            return .success(String.bridgeJSLiftParameter(_swift_js_pop_param_int32(), _swift_js_pop_param_int32()))
        case 1:
            return .failure(Int.bridgeJSLiftParameter(_swift_js_pop_param_int32()))
        case 2:
            return .flag(Bool.bridgeJSLiftParameter(_swift_js_pop_param_int32()))
        case 3:
            return .rate(Float.bridgeJSLiftParameter(_swift_js_pop_param_float64()))
        case 4:
            return .precise(Double.bridgeJSLiftParameter(_swift_js_pop_param_float64()))
        case 5:
            return .info
        default:
            fatalError("Unknown APIResult case ID: \(caseId)")
        }
    }
}

Pros

  • Much faster in practice (~90% faster than memory ABI for enum payloads).
  • Still flexible for variable-size payloads.

Cons

  • Requires careful push/pop ordering.
  • It's very stateful, so the state must be reset between calls.

Option 3: Flattened ABI

Use a fixed slot layout (e.g., tag: i32, i1: i32, i2: i32, f: f64) and pass values directly.

function lower(value) {
  switch (value.tag) {
    case APIResult.Tag.Flag:
      return [1, 0, 0]; // (i1=1, i2=0, f=0)
    case APIResult.Tag.Precise:
      return [0, 0, value.param0]; // (i1=0, i2=0, f=f64)
    // ...
  }
}
instance.exports.bjs_EnumRoundtrip_take(selfPtr, value.tag, i1, i2, f);
@_cdecl("bjs_EnumRoundtrip_take")
func bjs_EnumRoundtrip_take(_ selfPtr: UnsafeMutableRawPointer,
                            _ tag: Int32, _ i1: Int32, _ i2: Int32, _ f: Double) {
  switch tag {
  case 1: take(.failure(Int(i1)))
  case 2: take(.flag(i1 != 0))
  case 3: take(.rate(Float(f)))
  case 4: take(.precise(f))
  case 5: take(.info)
  }
}

Pros

  • Minimal boundary overhead.

Cons

  • Only works when payload space is finite at compile-time. (can't be used for indirectly recursive types)

Benchmark

I conducted a benchmark with a hacky manual patches. (you can see benchmark code on my branch. I manually modified the generated bridge-js.js other than the change tracked by git flat-abi.js stack-abi.js)

Test Memory-encoding (ms) Stack-based (ms) Flatten (ms)
EnumRoundtrip/takeEnum success 79.26 42.65 32.62
EnumRoundtrip/takeEnum failure 49.56 3.98 2.97
EnumRoundtrip/takeEnum flag 46.36 4.45 3.00
EnumRoundtrip/takeEnum rate 46.56 4.25 3.10
EnumRoundtrip/takeEnum precise 47.30 4.09 3.07
EnumRoundtrip/takeEnum info 44.19 3.26 2.96
EnumRoundtrip/makeSuccess 11.88 11.93 12.44
EnumRoundtrip/makeFailure 5.49 5.68 5.86
EnumRoundtrip/makeFlag 6.93 7.02 7.33
EnumRoundtrip/makeRate 7.12 7.16 7.55
EnumRoundtrip/makePrecise 7.04 7.18 7.59
EnumRoundtrip/makeInfo 4.48 4.55 4.86
StringRoundtrip/takeString 38.64 39.79 31.58
StringRoundtrip/makeString 9.89 9.52 10.13

My proposal

Given this, Option 2 looks like a reasonable baseline candidate, and Option 3 could be layered on as an optimization for fixed storage size types in the future. Does this direction make sense to you, or do you see other trade-offs we should weigh?

@krodak
Copy link
Contributor Author

krodak commented Aug 28, 2025

@kateinoigakukun thanks for in-depth analysis 🙇🏼‍♂️
Given your benchmark results, I think we should go with option 2, it seems like an overall approach we could reuse later when adding new types and that one seems more scalable for constructs with multiple values like case comprehensive(Bool, Bool, Int, Int, Double, Double, String, String, String) comparing to option 3.

I'll start migration to option 2 and update PR when ready 🙏🏻

@krodak krodak force-pushed the feat/enum-associated-values-primitive branch from 568df63 to 1e1952d Compare August 29, 2025 09:16
@krodak
Copy link
Contributor Author

krodak commented Aug 29, 2025

@kateinoigakukun PR updated for stack based solutions, couple of notes:

  • 2 set of stacks for ret / par to avoid conflicts on roundtrips
  • no particular place to clear the stacks, as we generate equal numbers of push / pop, no need for that
  • normal order on Swift side, inverse order on JS side
  • kept swift_js_push_string separate from swift_js_return_string (comparing to initial PR) to not mix those up
  • bridgeJSLiftParameter reuses existing bridgeJSLiftParameter mechanisms, while bridgeJSLowerReturn needs to use _swift_js_push<type> as default bridgeJSLowerReturn methods would not use stacks
  • it's fine to pass string to bridgeJSLiftParameter as 2 int32, but we need withUTF8 for lowering, hence strings stack

I will add comprehensive benchmark on separate branch to confirm performance based on parts of your branch, so will either add this to this PR or issue another PR against this branch 🙏🏻

@krodak krodak force-pushed the feat/enum-associated-values-primitive branch from 1e1952d to e89dfc0 Compare August 29, 2025 09:31
@krodak
Copy link
Contributor Author

krodak commented Aug 29, 2025

@kateinoigakukun here is the comparison for before / after changes 👌🏻

EnumRoundtrip Comparison

Test Before (ms) After (ms) Improvement
EnumRoundtrip/takeEnum success 69.35 38.27 44.8%
EnumRoundtrip/takeEnum failure 31.20 5.14 83.5%
EnumRoundtrip/takeEnum flag 29.79 5.13 82.8%
EnumRoundtrip/takeEnum rate 30.62 5.49 82.1%
EnumRoundtrip/takeEnum precise 30.53 5.41 82.3%
EnumRoundtrip/takeEnum info 19.35 4.05 79.1%
EnumRoundtrip/makeSuccess 18.08 15.24 15.7%
EnumRoundtrip/makeFailure 6.45 4.07 36.9%
EnumRoundtrip/makeFlag 8.20 4.07 50.4%
EnumRoundtrip/makeRate 8.73 4.29 50.9%
EnumRoundtrip/makePrecise 8.74 4.25 51.4%
EnumRoundtrip/makeInfo 5.34 3.50 34.5%
EnumRoundtrip/roundtrip 85.11 54.01 36.5%

ComplexResultRoundtrip Comparison

Test Before (ms) After (ms) Improvement
ComplexResultRoundtrip/takeEnum success 69.83 39.96 42.8%
ComplexResultRoundtrip/takeEnum error 70.75 40.50 42.8%
ComplexResultRoundtrip/takeEnum location 70.51 41.90 40.6%
ComplexResultRoundtrip/takeEnum status 65.79 35.84 45.5%
ComplexResultRoundtrip/takeEnum coordinates 37.50 8.10 78.4%
ComplexResultRoundtrip/takeEnum comprehensive 138.81 101.44 26.9%
ComplexResultRoundtrip/takeEnum info 20.68 5.13 75.2%
ComplexResultRoundtrip/makeSuccess 18.15 15.72 13.4%
ComplexResultRoundtrip/makeError 19.67 16.07 18.3%
ComplexResultRoundtrip/makeLocation 23.16 18.36 20.7%
ComplexResultRoundtrip/makeStatus 22.78 17.11 24.9%
ComplexResultRoundtrip/makeCoordinates 10.87 6.29 42.1%
ComplexResultRoundtrip/makeComprehensive 60.84 50.29 17.3%
ComplexResultRoundtrip/makeInfo 5.38 3.25 39.6%
ComplexResultRoundtrip/roundtrip 86.63 55.54 35.9%

Here is the branch with working benchmarks for both enums and previous approach: feat/bridgejs-benchmark-buffer

Copy link
Member

@kateinoigakukun kateinoigakukun left a comment

Choose a reason for hiding this comment

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

Looks good to me. I think we can centralize JS glue codegen for lifting/lowering payload types by reusing IntrinsicJSFragment but, let's incubate on the main branch!
Thanks as always!

@kateinoigakukun kateinoigakukun merged commit f9a09a3 into swiftwasm:main Aug 29, 2025
9 checks passed
@krodak
Copy link
Contributor Author

krodak commented Aug 29, 2025

@kateinoigakukun I'll give it a go after the weekend to use IntrinsicJSFragment for helper functions fragments and align approach, so will issue a new PR with that, thanks for suggestion! 👍

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.

BridgeJS: Support enum as parameter/return type
2 participants