Out of curiosity, why does Haxe transform function types into dynamics after compilation?
What are the performance implications of doing so?
Out of curiosity, why does Haxe transform function types into dynamics after compilation?
What are the performance implications of doing so?
Will have to bump this, following my ticket here Function types being converted to dynamic types · Issue #997 · HaxeFoundation/hxcpp · GitHub which does not seem to be taken seriously as an issue (in spite of the fact it can cause some major stuttering during gameplay due to the GC performing unnecessary collections at runtime):
Using HxScout showed a large object footprint when using function types for callbacks, i.e. var func : Float â Void; and then calling func(3.0). A âFloatâ object was created each frame which then needed to be garbage collected. This incurs a big cost at runtime because any time a callback is required, the garbage collector is clearing up potentially thousands of objects that have been seemingly generated for no reason.
Does anyone know why this is the case?
The short answer is that Float -> Void
could mean a lot of things:
function main() {
var f:Float->Void;
f = function(i:Null<Float>) {}
f(3.0);
f = function(d:Dynamic) {}
f(3.0);
}
So in the general case, the call-site doesnât know what itâs actually calling and has to be conservative.
Hi Simn. Many thanks for answering, I appreciate it.
I donât understand why it would need to be conservative in this case. In the examples you just listed surely your function argument would change as per your use-case:
var f = Null<Float> -> Void;
or
var f = Dynamic -> Void;
My understanding is that because it gets transformed to dynamic at compile (despite the fact it should know the type as given) that at runtime an inference has to be made that leads to the creation of âFloatâ hence the sudden explosion of objects in the gameloop.
Why not just enforce an additional type safety check on the function parameter types?
I wanted to make a simple example, but I suppose itâs a bit too simple. Maybe this is better:
function invoke(f:Float->Void) {
f(3.0);
}
function main() {
invoke(function(i:Null<Float>) {});
invoke(function(d:Dynamic) {});
}
The call-site has no idea what itâs actually calling at compile-time.
Based on the OPâs initial issues, I would think that perhaps if you provide specific types, the Haxe compiler shouldnât make assumptions and instead generate the necessary C/C++ style callback as per the callback defined in Haxe.
If there is a Dynamic
type being supplied in a callback definition in Haxe, then assumptions have to be made and therefore the argument regarding the âcall-site having no idea what is actually compilingâ actually makes sense. However, I donât agree that all circumstances should mean assumptions should be made every time.
If you type f:Float -> Void
, you would expect a callback function of a similar type to be generated in the output, since the types are known at compile time. So, you should see: void (*SomeCallback)(float)
in the C++ output. Obviously thatâs a C-style callback (I donât use C++ often), but I think the Haxe compiler should at least infer that if specific types are given, no assumptions need to be made about the call-site.
If the values given do not work, I think thatâs the programmerâs fault not the fault of Haxe.
For clarification: why would a Float
be created each call when using the Function type? Is it because of dynamics or because of the casting from a C++ float to a Haxe Float
?
As in tieneryâs reply, why canât the compiler make no assumptions and simply generate function pointers (templates/callables, etc) when it sees a function expression? Maybe for cases like this:
public var f : Float->Void;
public function new() {
this.f = func1;
this.f(3.0)
this.f = func2;
this.f(3.0)
}
private function func1(value : Float) : Void { }
private function func2(value : Float) : Void { }
For background, I stumbled across this issue in two places:
If Float->Void doesnât evaluate to something other, you can do following (cpp target)
import haxe.Timer;
class Main {
static function func1(value : Float) : Void { }
static function func2(value : Float) : Void { }
static function main() {
var t;
t = Timer.stamp();
for ( i in 0...1000000 ) {
var f:Float->Void = func1;
f(3.0);
f = func2;
f(3.0);
}
t = Timer.stamp() -t;
trace(t);
t = Timer.stamp();
for ( i in 0...1000000 ) {
var fs = cpp.Function.fromStaticFunction(func1);
fs(3.0);
fs = cpp.Function.fromStaticFunction(func2);
fs(3.0);
}
t = Timer.stamp() -t;
trace(t);
}
}
Main.hx:18: 0.0541986
Main.hx:29: 0.0039293
Approx. 13 times fasterâŠ
Hi there, thanks.
Issue with this approach is that it doesnât work if I am working with anything other than static functions. Also, removes the ability to make platform agnostic code. Iâd have to hide this away in an abstraction which I wouldnât want to do.
If you donât want abstracts (the most powerful runtime Haxe feature), you can always make a macro (the most powerful compile time feature).
Abstracts are a compile-time feature as well.
If inlined, otherwise static functions (especially @:from) are used at runtime. Not to mention what happens if underlying type is dynamic.
The reason function pointers or fancier templates arenât used is due to dynamic. Currently hxcpp closures generated from haxe anoymous functions all extend the hx::LocalFunc
(or hx::LocalThisFunc
) class and the HX_BEGIN_LOCAL_FUNC
you see in the generated cpp defines a class which extends one of those classes and defines a _hx_run
function which contains the users actual code.
We could try and make a fancier closure class using template pack parameters which might look something like this.
template<class TReturn, class... TArgs>
struct HXCPP_EXTERN_CLASS_ATTRIBUTES TypedLocalFunc : LocalFunc
{
virtual TReturn _hx_run(TArgs... args) = 0;
};
and as a quick test to ensure things work we can hand write a closure to see what the the cpp generator / hxcpp macros could be changed to potentially output. In this case our closure class inherits from a specialisation of our closure template.
struct HXCPP_EXTERN_CLASS_ATTRIBUTES testTyped : hx::TypedLocalFunc<float, float, int>
{
float _hx_run(float v1, int v2)
{
return v1 * v2;
}
// These are needed to ensure compatibility with wrapping the typed closure in Dynamic.
::Dynamic __Run(const Array< ::Dynamic> &inArgs) { return _hx_run( inArgs[0], inArgs[1] ); return null(); }
::Dynamic __run(const Dynamic &inArg0, const Dynamic &inArg1) { return _hx_run( inArg0, inArg1 ); return null(); }
};
At first glace this all seems to work rather well. we can then write code like this and it works!
hx::TypedLocalFunc<float, float, int>* func = new testTyped();
Dynamic dyn = Dynamic(func);
::haxe::Log_obj::trace(v1->_hx_run(5.7, 7), null());
::haxe::Log_obj::trace(dyn(5.7, 7), null());
We still retain the old Dynamic calling and if we have the actual closure pointer the only cost we pay is the virtual function call, not a potential GC collection from primitives being boxed.
This approach all falls apart when dynamic is involved. If we take a look at a second very similar closure object where the first argument is replaced with Dynamic from a haxe point of view these two functions should be compatible, but in cpp theyâre not.
struct HXCPP_EXTERN_CLASS_ATTRIBUTES otherTestTyped : hx::TypedLocalFunc<float, Dynamic, int>
{
float _hx_run(Dynamic v1, int v2)
{
return v1 + v2;
}
::Dynamic __Run(const Array< ::Dynamic> &inArgs) { return _hx_run( inArgs[0], inArgs[1] ); return null(); }
::Dynamic __run(const Dynamic &inArg0, const Dynamic &inArg1) { return _hx_run( inArg0, inArg1 ); return null(); }
};
The problems start to appear once we try and cast between these two objects, attempting to do so will result in a null pointer because as far as c++ is concerned two different template specialisations are entirely different classes.
hx::TypedLocalFunc<float, float, int>* v1 = new testTyped();
hx::TypedLocalFunc<float, Dynamic, int>* v2 = new otherTestTyped();
v1 = (hx::TypedLocalFunc<float, float, int>*)v2;
// v1 is now null!
while this could probably be solved with some sort of adapter function in true âAll problems in computer science can be solved by another level of indirectionâ style, but its a fair bit more work than using function pointers or templates. While it would be nice to not have primitives be boxes in these situations its probably eaiser to re-work any performance sensitive code to avoid dynamic functions and the current Dynamic functions are probably to just to make the cpp implementation easier.
Hi Aidan, thanks for the reply. Very insightful
I know other compilers can handle delegates/function types elegantly, yet it seems like the limitation here is because we initially transpile to C++ and then must play by its rules (i.e. how it handles dynamics).
I donât know if C++ supports function overloading but if it does my naive initial thought would be to generate something like this (in pseudocode):
TypedLocalFunc_FFI<float, float, int> * func1 = new testTyped();
TypedLocalFunc_FDI<float, dynamic, int> * func2 = new otherTestTyped();
TypedLocalFunc(float v1, float v2, int v3) {
// Call func1
}
TypedLocalFunc(float v1, dynamic v2, int v3) {
// Call func2
}
I will have a look into this later to see if I can solve my issue, as I feel function types do lead to more elegant code. It makes for simpler eventing, for example. Itâs just a shame that the secret boxing and unboxing of primitive types leads to very noticable frame stuttering at runtime.
Interesting hypothesis. My brain just thinks, âprogrammerâs are intelligent and the output should work. If it doesnât work, the programmer did something wrong.â
I understand the concept that if Haxe were to generate an output similar to what you provided, you could say that Haxe throws a compile error stating, in this case (just on the C++ target), mixing Dynamic
types with real types in function calls is invalid, which actually reinforces type-safety and forces programmers to be more careful with mixing unknown types with known types.
If working with unknown types, they should separated from function arguments whose types are known at compile time. This also reduces ambiguity between function calls. But, this is a decision by the Haxe team of course, Iâm just throwing a suggestion in here while weâre on the topic.
I had another look at this yesterday evening after having an idea and managed to get something working with Dynamic / nullable function parameters (I hope you like templates).
First thing I did was create a closure object which extends the ObjectPtr
object, this is inkeeping with the hxcpp style and allows us to throw exceptions on null pointers. I also overloaded ()
to call the wrapped function.
template<class TReturn, class... TArgs>
class Closure : public ObjectPtr<TypedLocalFunc<TReturn, TArgs...>>
{
public:
Closure(TypedLocalFunc<TReturn, TArgs...>* ptr) : ObjectPtr<TypedLocalFunc<TReturn, TArgs...>>(ptr) {}
TReturn operator()(TArgs... args)
{
if (!mPtr)
{
hx::Throw( HX_NULL_FUNCTION_POINTER );
}
return mPtr->_hx_run(args...);
}
};
Another key part of this Closure object is an implicit user conversion function to other closure types, this is implemented by creating a wrapper function which simply calls the wrapped function. This means that only dynamic / nullable arguments will box primitive types.
template<class TNewReturn, class... TNewArgs>
operator Closure<TNewReturn, TNewArgs...>() const
{
struct HXCPP_EXTERN_CLASS_ATTRIBUTES AdapterFunction : TypedLocalFunc<TNewReturn, TNewArgs...>
{
TypedLocalFunc<TReturn, TArgs...>* wrapping;
AdapterFunction(TypedLocalFunc<TReturn, TArgs...>* _wrapping) : wrapping(_wrapping) { }
TNewReturn _hx_run(TNewArgs... args)
{
return wrapping->_hx_run(args...);
}
// gc guff
inline void DoMarkThis(hx::MarkContext *__inCtx) { HX_MARK_MEMBER(wrapping); }
#ifdef HXCPP_VISIT_ALLOCS
inline void DoVisitThis(hx::VisitContext *__inCtx) { HX_VISIT_MEMBER(wrapping); }
#endif
};
return Closure<TNewReturn, TNewArgs...>(new AdapterFunction(GetPtr()));
}
Looking back at the example in my above post you can now convert from one function type to another function type if some of the parameters are replaced with dynamic.
hx::Closure<float, float, int> v1 = hx::Closure<float, float, int>(new testTyped());
hx::Closure<float, Dynamic, int> v2 = v1;
::haxe::Log_obj::trace(v1(5.7, 7), null());
::haxe::Log_obj::trace(v2(5.7, 7), null());
Both of these variables now hold valid functions and can be successfully invoked. There is an extra virtual function call with v2
(and through repeated casting / replacing parameters to and from dynamic the number of wrappers would increase), but if youâre using dynamic thats probably a price youâre willing to pay.
There is another Dynamic
related edge case and thats a round trip through dynamic where one of the parameters has been swapped with dynamic / is nullable on the other side (e.g. Int->Int
=> Dynamic
=> Dynamic->Int
). The same adapter function style can be used but this time I quickly added an extra constructor to handle it (maybe it would be better handle in dynamic or the logic shared).
Closure(hx::Object* ptr)
{
struct HXCPP_EXTERN_CLASS_ATTRIBUTES AdapterFunction : TypedLocalFunc<TReturn, TArgs...>
{
Dynamic wrapping;
AdapterFunction(Dynamic _wrapping) : wrapping(_wrapping) {}
TReturn _hx_run(TArgs... args)
{
return wrapping(args...);
}
inline void DoMarkThis(hx::MarkContext *__inCtx) { HX_MARK_MEMBER(wrapping); }
#ifdef HXCPP_VISIT_ALLOCS
inline void DoVisitThis(hx::VisitContext *__inCtx) { HX_VISIT_MEMBER(wrapping); }
#endif
};
mPtr = new AdapterFunction(ptr);
}
Once this is done Dynamic round tripping also works with Dynamic swapping on function arguments.
hx::Closure<float, Dynamic, int> v1 = hx::Closure<float, Dynamic, int>(new otherTestTyped());
Dynamic v2 = Dynamic(v2);
hx::Closure<float, float, int> v3 = hx::Closure<float, float, int>(v2.mPtr);
::haxe::Log_obj::trace(v1(5.7, 7), null());
::haxe::Log_obj::trace(v2(5.7, 7), null());
::haxe::Log_obj::trace(v3(5.7, 7), null());
This is all something I threw together in about an hour yesterday evening so not battle tested and there are probably more edge cases Iâve not thought about and ways that the number of warppers could be reduced in some situations. But it could be one way forward for the cpp generator to be updated to output typed function objects while still keeping dynamic support.
I am wondering how feasible it would be to fork from the hxcpp source to see if it is possible to get this working, or does further work need to be done inside the Haxe compiler itself?
An additional issue I thought of that might arise from type unboxing would be when using the haxe-concurrency
library, which relies on function types for the definition of its tasks.
After I figured out the dynamic swapping I did have a go at changing the cpp generator to support typed functions. I managed to get it working to a state where the following haxe code
function main() {
final f = () -> {
trace('hello world!');
}
other(f);
other(another);
}
function other(_f : Void->Void) {
_f();
}
function another() {
trace('another!');
}
Outputs the following cpp, as you can see it has a templated closure types instead of dynamic.
void Main_Fields__obj::main()
{
HX_BEGIN_LOCAL_FUNC_S0(::hx::LocalFunc, _hx_Closure_0)
HXARGC(0) void _hx_run()
{
HX_STACKFRAME(&_hx_pos_195033e4c87195a0_3_main)
::haxe::Log_obj::trace(HX_("hello world!", dd, fc, f4, 73), ::hx::SourceInfo(HX_("Main.hx", 05, 5c, 7e, 08), 3, HX_("_Main.Main_Fields_", 76, cc, 48, 1a), HX_("main", 39, 38, 56, 48)));
}
HX_END_LOCAL_FUNC0((void))
HX_STACKFRAME(&_hx_pos_195033e4c87195a0_1_main)
HX_VARI(::hx::Closure<void HX_COMMA void>, f) = ::hx::Closure<void, void>(new _hx_Closure_0());
::_Main::Main_Fields__obj::other(f);
::_Main::Main_Fields__obj::other(::_Main::Main_Fields__obj::another_dyn());
}
STATIC_HX_DEFINE_DYNAMIC_FUNC0(Main_Fields__obj, main, (void))
void Main_Fields__obj::other(::hx::Closure<void, void> _f)
{
HX_STACKFRAME(&_hx_pos_195033e4c87195a0_11_other)
HX_STACK_ARG(_f, "_f")
_f();
}
STATIC_HX_DEFINE_DYNAMIC_FUNC1(Main_Fields__obj, other, (void))
void Main_Fields__obj::another()
{
HX_STACKFRAME(&_hx_pos_195033e4c87195a0_15_another)
::haxe::Log_obj::trace(HX_("another!", fe, d3, 79, fb), ::hx::SourceInfo(HX_("Main.hx", 05, 5c, 7e, 08), 15, HX_("_Main.Main_Fields_", 76, cc, 48, 1a), HX_("another", c3, 16, 5a, f3)));
}
STATIC_HX_DEFINE_DYNAMIC_FUNC0(Main_Fields__obj, another, (void))
(Iâve just pushed the change as I forgot about it until now GitHub - Aidan63/haxe at cpp_typed_funcs).
Currently the generated code fails at cpp compilation as I havenât gone through and updated the hxcpp parts to get this small code sample fully running. In the above code I think the main things to change would be adding the hx:::Closure type which I posted a while back and updating the LOCAL_FUNC and STATIC_HX_DEFINE_DYNAMIC_FUNC macros and the code they call to work with that new type.
I want to get back around to it at some point but Iâm not sure when, so if anyone else wants to have a stab at continuing this approach then be my guest.
Iâm currently working on a macro that might circumvent the value type unboxing, albeit likely with more overhead. My only concern is that this idea might not work with threads.
Iâll post the macro here if I get it working. Otherwise, I guess Iâll end up learning OCaml.
Edit: The proof of concept worked. Even supports threads⊠I need to clean up the macro and I will post here. There is some additional overhead in the sense that every time you assign to a function type you create a new object.
Hi All,
Went with Ilir-Liburnâs suggestion in the end and built a macro⊠The library can be found here: haxe-delegates (0.0.0)
I had actually been musing over this idea for a year or so before getting it in, and Iâm happy to say it works. For the Hxcpp
target, the delegates appear to perform 40% faster than function types, and 200% faster when inlined. It would be nice to see how it performs on other targets.
You can use it by going:
var delegate : Delegate<(Int, Int)->Int> = DelegateBuilder.from(myFunction);
public function myFunction(a : Int, b : Int) : Int {
return a + b;
}
delegate.call(4, 3);
Essentially, all the macro does is wrap function pointers and calls into an object. It had some serendipitous side effects, such as type-strictness. The compiler will recognise if youâre trying to pass Null<Int>
where it expects an Int
and it will complain, thereby putting the onus back on the developer.
It also handles inlining quite nicely, as delegate objects are given private access to the class that implements them via a _parent
variable, so it has a view onto any variables declared externally to itself.
Obviously as a first release, thereâs still limited functionality, but this is a proof of concept that such a thing is possible and possibly even preferred in some cases. I may be able to circumvent excessive object creation using pooling and weak-references, and would like to get full support for local functions in at some point.
Kind regards.
© 2018-2020 Haxe Foundation - Powered by Discourse