Fable: Proper generic parameters / flexible interface call support

Created on 31 Aug 2018  路  15Comments  路  Source: fable-compiler/Fable

Description

It seems that for some reason Fable2 not able to resolve the generic parameters / flexible interfaces properly (while the same thing looks working in Fable1). Would it be possible to call the interface methods (as interfaces lot's of the time will be erased) as a fallback mechanism for not-yet resolved generic calls? How Fable1 still able to resolve those generic parameters?

https://github.com/fable-compiler/Fable/blob/6453880b0bfd731f282fc52bf24831a7a86ef1a3/src/dotnet/Fable.Compiler/Transforms/FSharp2Fable.Util.fs#L794

Repro code

[<Interface>]
type IFunc1 =
    abstract member Func1: int32 -> int32

[<Class>]
type Test<'T when 'T:> IFunc1>(instance: 'T) =
    member this.Call(x: int32) =
        instance.Func1(x)

Expected and actual results

Fable1 level functionality should also work in Fable2.

Fable1 output (from online repl):

import { setType } from "fable-core/Symbol";
import _Symbol from "fable-core/Symbol";
export class Test {
  [_Symbol.reflection]() {
    return {
      type: "Test.Test",
      properties: {}
    };
  }

  constructor(instance) {
    this.instance = instance;
  }

  Call(x) {
    let copyOfStruct = this.instance;
    return copyOfStruct.Func1(x) | 0;
  }

}
setType("Test.Test", Test);

Fable2 output (from online repl):

import { declare } from "fable-core/Types";
export const Test$00601 = declare(function Test$00601(instance) {
  const $this$$1 = this;
  $this$$1.instance = instance;
});
export function Test$00601$$$$002Ector$$2B595(instance) {
  return this != null ? Test$00601.call(this, instance) : new Test$00601(instance);
}
export function Test$00601$$Call$$Z524259A4(this$, x) {
  let copyOfStruct = this$.instance;
  return copyOfStruct.Func1(x) | 0;
}

Related information

  • Fable version (dotnet fable --version): Online Fable1, Fable2 repl
  • Operating system:
dev2.0 discussion

All 15 comments

I was afraid this would come up at some point :) This is due to how Fable 2 compiles interfaces, the differences are:

  • Fable 1: Interface members were attached directly to the JS class so no casting was necessary. The main problem is there can be name conflicts if different interfaces use the same member name (class members can be mangled but not interfaces').

  • Fable 2: Interface members are not attached to the class. Instead Fable creates cast functions that wrap the type with a JS object containing the interface members (and a reference to the original object).

The main advantages of Fable 2 approach are:

  • There won't be name clashes.
  • Interfaces can behave differently than class members. This is important because interfaces are used to interact with JS so names cannot be mangled.
  • If the interface is not used, all the implementation code will be removed from the final bundle by tree shaking.

However, as you've seen the main disadvantage is:

  • Fable 2 needs to know at compile time the concrete type casted to an interface. This prevents the use of flexible types as in the case above.

Note that type testing and casting back an interface to a concrete type is possible because the interface keeps a reference to the original object.

I've already thought about this, and the only solution I could find is to attach an interface dictionary to the object so we can find the cast function at runtime. But this would also bring some disadvantages:

  • Tree shaking won't be possible anymore as the dictionary attached to the type contains references to all the cast functions.
  • There will be more runtime checks.
  • This favors polymorphic code which, as discussed before, prevents some optimizations in JS engines. Forcing the user to cast a type to an interface before sending it into a function instead of using generics may be less convenient, but it will lead developers to write more performant code.

And please don't ask for a compiler flag to change the behaviour :wink: This generates lots of problems and increase maintenance costs exponentially.

I personally prefer the Fable 2 way of doing things. One of the main goal of Fable 2 was the performance improvements and more important the reducing bundle size.

I am perfectly ok to make some trade off for archiving this goal. Here being the requirement of the explicit cast.

@alfonsogarciacaro Thank you for the explanation! What do you think about directed (with an attribute?) code specialization / instantiation ? I mean something like .NET engine do when the generic type argument is a value type.

This could significantly speed up Fable2 interface calls in a call heavy environment:
https://github.com/fable-compiler/Fable/issues/1318#issuecomment-417604830

I'm expecting to be quite busy this month with work and conferences so maybe we'll need to freeze Fable 2 for optimization improvements until the release (unless another person implements them) and leave them to Fable 2.1. Anyways, how can this be done in JS (we don't have value types there)? I know a bit about virtual tables, but I don't know exactly how .NET deals with interface calls in generic functions. Would it be possible to post an example of how the generated JS code would look like in your proposal?

@alfonsogarciacaro Understood and no complaints here (workaround exists even if it's a bit ugly). The full generics story is not going to be easy, and probably worth some discussion with @dsyme. It may possible that some of the ideas could be adapted to JS: http://mattwarren.org/2018/03/02/How-generics-were-added-to-.NET/

My focus right now is call performance (https://github.com/fable-compiler/Fable/issues/1318#issuecomment-417604830), and what I would like to suggest as a "workaround" is lot simpler than the full generics story (no dynamic codegen), something like what the CoreRT do with pre-generated generic instatiation: https://github.com/Microsoft/visualfsharp/issues/4954#issuecomment-391986981

[<Interface>]
type IFunc1 =
    abstract member Func1: int32 -> int32

type MyFunc1() = 
  member this.Func1(x:int32) = x+2
  interface IFunc1 with
    member this.Func1(x: int32) = this.Func1(x)

[<Class>]
type TestInterface(instance: IFunc1) =
    member this.Call(x: int32) =
        instance.Func1(x)

[<Generate(MyFunc1)>]
type TestGeneric<'T when 'T:> IFunc1>(instance: 'T) =
    member this.Call(x: int32) =
        instance.Func1(x)

type TestSpecialized(instance: MyFunc1) =
    member this.Call(x: int32) =
        instance.Func1(x)

let myF = MyFunc1()
let myCI = TestInterface(myF)
let myCG = TestGeneric(myF)
let myCS = TestSpecialized(myF)

Basically if the TestGeneric(myF) instantiated with a type that listed in the Generated attribute MyFunc1 it should have exactly the same code generated as TestSpecialized(myF). Is it possible to do something like this with AST replacements / rewrite ? Unfortunately I am not that familiar with F# AST/TAST yet, but eventually I will try it out. The non-static dynamic generic instantiation will be a different story as it will be require dynamic JS codegen + eval...

Hmm, I'm not very convinced of such an attribute because it bypasses the type checker and could be error-prone. Maybe the solution is to just attach interface members to the prototype as we do for abstract members (this would bring potential name clashes and prevent tree shaking though). I've pushed a quick-experiment for this in the attach-interfaces branch. It requires fixing but if the REPL builds we'll be able to see if there's any performance improvement and whether it's worth exploring this path or not (cc @ncave). Can you also please give it a try @zpodlovics?

@alfonsogarciacaro Why would it bypass the type checker? The code still have to typecheck properly, using the same rules as the F# .NET target. The specialization is only accepted if the attribute type parameter [<Generate(MyFunc1)>] that would properly typecheck so MyFunc1 will be accepted as <'T when 'T:> IFunc1>. However the runtime dynamic code generation part (in the browser by generating javascript code + eval) is replaced with static code pre-generation (by the Fable).

If I'm understanding it correctly, the [<Generate(MyFunc1)>] attribute will assume that 'T in <'T when 'T:> IFunc1> is MyFunc1. But the F# type checker would still allow to pass other types different than MyFunc1 (if they implement the interface) so this may not hold true (unless we add an extra type checking for Fable which starts complicating thing). We would have to assume that MyFunc1.Call is equivalent to IFunc1.Call too.

In any case, it would be interesting to see the results if you could PR a PoC of this.

@alfonsogarciacaro I'm not sure the REPL benchmark is the right measure (we should be measuring times for several diverse source files), but still, the attach-interfaces branch produces a clear 5% improvement.

@alfonsogarciacaro Here are the REPL bundle sizes:

master branch:
- bundle.js:     12,122,336 bytes
- bundle.min.js:  3,773,811 bytes

attach-interfaces branch:
- bundle.js:     12,106,917 bytes
- bundle.min.js:  3,770,977 bytes

IMO +5% perf and slightly lower bundle size is quite good.

So it doesn't have a big impact on bundle size (which makes sense because at the end interface methods are normally used anyways) and solves issues like flexible parameters. It has the risk for name clashes but we already have limitation in interface names (no overloads) so maybe we can live with that. Thanks for checking @ncave, I will send a PR :+1:

@alfonsogarciacaro Amazing work! Now the pure interface calls (TestInterface(instance: IFunc1) like usage) are significantly faster (re)using my codec benchmark form here(https://github.com/fable-compiler/Fable/issues/1318#issuecomment-416240702):

0: 3.799000(s) time, 3799.000000(ns/msg) average latency, 263227.165043(msg/s) average throughput - message size: 146 - GC count: 1
0: 3.747000(s) time, 3747.000000(ns/msg) average latency, 266880.170803(msg/s) average throughput - message size: 146 - GC count: 1
1: 3.759000(s) time, 3759.000000(ns/msg) average latency, 266028.198989(msg/s) average throughput - message size: 146 - GC count: 1
1: 3.778000(s) time, 3778.000000(ns/msg) average latency, 264690.312335(msg/s) average throughput - message size: 146 - GC count: 1
2: 3.759000(s) time, 3759.000000(ns/msg) average latency, 266028.198989(msg/s) average throughput - message size: 146 - GC count: 1
2: 3.781000(s) time, 3781.000000(ns/msg) average latency, 264480.296218(msg/s) average throughput - message size: 146 - GC count: 1
3: 3.822000(s) time, 3822.000000(ns/msg) average latency, 261643.118786(msg/s) average throughput - message size: 146 - GC count: 1
3: 3.778000(s) time, 3778.000000(ns/msg) average latency, 264690.312335(msg/s) average throughput - message size: 146 - GC count: 1
4: 3.779000(s) time, 3779.000000(ns/msg) average latency, 264620.269913(msg/s) average throughput - message size: 146 - GC count: 1
4: 3.737000(s) time, 3737.000000(ns/msg) average latency, 267594.327000(msg/s) average throughput - message size: 146 - GC count: 1
5: 3.710000(s) time, 3710.000000(ns/msg) average latency, 269541.778976(msg/s) average throughput - message size: 146 - GC count: 1
5: 3.703000(s) time, 3703.000000(ns/msg) average latency, 270051.309749(msg/s) average throughput - message size: 146 - GC count: 1
6: 3.712000(s) time, 3712.000000(ns/msg) average latency, 269396.551724(msg/s) average throughput - message size: 146 - GC count: 1
6: 3.703000(s) time, 3703.000000(ns/msg) average latency, 270051.309749(msg/s) average throughput - message size: 146 - GC count: 1
7: 3.724000(s) time, 3724.000000(ns/msg) average latency, 268528.464017(msg/s) average throughput - message size: 146 - GC count: 1
7: 3.713000(s) time, 3713.000000(ns/msg) average latency, 269323.996768(msg/s) average throughput - message size: 146 - GC count: 1
8: 3.712000(s) time, 3712.000000(ns/msg) average latency, 269396.551724(msg/s) average throughput - message size: 146 - GC count: 1
8: 3.717000(s) time, 3717.000000(ns/msg) average latency, 269034.167339(msg/s) average throughput - message size: 146 - GC count: 1
9: 3.765000(s) time, 3765.000000(ns/msg) average latency, 265604.249668(msg/s) average throughput - message size: 146 - GC count: 1
9: 3.720000(s) time, 3720.000000(ns/msg) average latency, 268817.204301(msg/s) average throughput - message size: 146 - GC count: 1
10: 3.708000(s) time, 3708.000000(ns/msg) average latency, 269687.162891(msg/s) average throughput - message size: 146 - GC count: 1
10: 3.709000(s) time, 3709.000000(ns/msg) average latency, 269614.451335(msg/s) average throughput - message size: 146 - GC count: 1

Flamegraph:
https://gist.github.com/zpodlovics/60ce35756887c4543a6306bbbb06e1f0

Well, it's not amazing work, it's just undoing all the work I did for interface casting in Fable 2 ;) But I'm totally ok with that if it's for the better :+1: Thanks a lot for posting the perf stats, this is really helpful to improve Fable!

Fable 2 has finally been released with interfaces attached to the prototype, should we close this?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

et1975 picture et1975  路  3Comments

funlambda picture funlambda  路  4Comments

rommsen picture rommsen  路  3Comments

MangelMaxime picture MangelMaxime  路  3Comments

alfonsogarciacaro picture alfonsogarciacaro  路  3Comments