Skip to content

simplify ternary transpilation#310

Merged
tomblind merged 22 commits intoTypeScriptToLua:masterfrom
TheLartians:simplified-ternary
Jan 17, 2019
Merged

simplify ternary transpilation#310
tomblind merged 22 commits intoTypeScriptToLua:masterfrom
TheLartians:simplified-ternary

Conversation

@TheLartians
Copy link
Copy Markdown
Contributor

@TheLartians TheLartians commented Dec 21, 2018

Transpile the ternary operator using and/or and boxing/unboxing. This reduces the cost of the ternary operator from a library call and the creation of two closures to a table allocation and lookup.

Example

declare function f(): number | undefined;
const a = true ? 0 : f();
const b = true ? f() : 0;

Before

-- Lua Library Imports
function __TS__Ternary(condition,cb1,cb2)
    if condition then
        return cb1()
    else
        return cb2()
    end
end

local a = __TS__Ternary(true, function() return 0 end, function() return f() end);
local b = __TS__Ternary(true, function() return f() end, function() return 0 end);

After
LuaJIT:

local a = ((true) and (0) or (f()));
local b = ((true) and {f()} or {0})[1];

Other:

local a = ((true) and (0) or (f()));
local b = ((true) and function() return f(); end or function() return 0; end)();

@tomblind
Copy link
Copy Markdown
Collaborator

It would probably be good to remove the lib function altogether.

@TheLartians
Copy link
Copy Markdown
Contributor Author

ok, I think I got it all now.

@lolleko
Copy link
Copy Markdown
Member

lolleko commented Dec 22, 2018

I did some benchmarks and I'm getting mixed results:

In Lua 5.3 IIFEs are always faster than box/unboxing around 400%.

In LuaJIT IIFEs are faster around 20-50% for small arrays.
12% faster for an array size up to around 5000000.
After that boxing/unboxing is significantly faster (500%).

Tested Code:

-- box/unbox
for i=1, arrSize do
  y = ((arr[i]) and {true} or {false})[1]
end
-- IIFE
for i=1, arrSize do
  z = (function (condtion, val1, val2)
    if condtion then
      return val1
    else
      return val2
    end
  end)(arr[i], true, false)
end

I would argue that 99.9% of the time arrays have less than 50 Million entries, in which case IIFEs are defintly faster. Also I don't think you usually find ternaries that often in tight loops, but that's pure speculation.

So we should still remove the lib call but replace it with IIFEs instead of boxing/unboxing.

@TheLartians
Copy link
Copy Markdown
Contributor Author

Wow thats really interesting. I had no idea IIFEs are that well optimised in lua. We should definitely use IIFEs then.

@lolleko
Copy link
Copy Markdown
Member

lolleko commented Dec 22, 2018

Did some more testing seems like:

local libTernary  = function (condtion, val1, val2)
  if condtion then
    return val1
  else
    return val2
  end
end

for i=1, arrSize do
  z = libTernary(arr[i], true, false)
end

performs exactly the same as:

for i=1, arrSize do
  z = (function (condtion, val1, val2)
    if condtion then
      return val1
    else
      return val2
    end
  end)(arr[i], true, false)
end

in both JIT and 5.3, so we might aswell keep the lib function since its a bit more readable and adjust it to take the 2 raw expressions as parameter without wrapping them in extra functions.

@TheLartians
Copy link
Copy Markdown
Contributor Author

However, the wrapping is necessary to perform lazy evaluation which otherwise might introduce unwanted side effects. Can you also benchmark the performance with function wrapping?

@lolleko
Copy link
Copy Markdown
Member

lolleko commented Dec 22, 2018

Okay getting somewhat different results now:

--lua maxntest.lua
arrSize:	5000000

boxUnboxTime time:	2.580266
IIFE time:	0.634437
lib time:	0.630949
wrap time:	0.640842
--luajit maxntest.lua
arrSize:	5000000

boxUnboxTime time:	0.054687
IIFE time:	0.424994
lib time:	0.049987
wrap time:	0.770319

That might be the case because I do all these tests in one file, should probably be executed all separately to make sure they all have the same conditions (stack, heap...).

Here is the code I used: https://gist.github.com/lolleko/8cd4be25b91a8c13c6472863aa6b3868

Feel free to perform your own benchmarking.

I think if we introduce a performance optimising change like this we should make sure it actually performs better.

@TheLartians
Copy link
Copy Markdown
Contributor Author

Completely agree. Here I just assumed that function calls must be slower than table allocation and lookup. I will try some own benchmarking later.

@TheLartians
Copy link
Copy Markdown
Contributor Author

TheLartians commented Dec 22, 2018

Thanks for reminding me to always benchmark before optimising. So as boxing/unboxing is expensive we should definitely replace it with an IIFE. In the implementation below only one call is necessary as opposed to two when using the library function. So it seems performance using IIFE is better on all non-jit implementations.

Code: https://gist.github.com/TheLartians/0b8bbdc551b67b7205e347df7525c205
Benchmark:

lua5.3 benchmark.lua 
boxUnboxTime time:	1.517611
IIFE time:	0.322223
lib time:	0.420415
lua5.1 benchmark.lua 
boxUnboxTime time:	1.444085
IIFE time:	1.084926
lib time:	1.961076
luajit benchmark.lua 
boxUnboxTime time:	0.041535
IIFE time:	0.254946
lib time:	0.371651

@TheLartians
Copy link
Copy Markdown
Contributor Author

TheLartians commented Dec 22, 2018

In my benchmark when using liajit, boxing outperforms the other approaches by an order of magnitude, so for luajit we should use the boxing approach.
PS: I adjusted the previous benchmark code so that a variable is captured in the closures and called the resulting function to resolve the conditional statement.

@DoctorGester
Copy link
Copy Markdown
Contributor

DoctorGester commented Dec 22, 2018

IMO we should have no boxing/unboxing on things guaranteed not to produce side effects, which is the most common case when referring to literals or variables

@TheLartians
Copy link
Copy Markdown
Contributor Author

TheLartians commented Dec 27, 2018

@DoctorGester the boxing/closures are necessary even without side effects when dealing with boolean or possibly undefined values, when using the and/or syntax. The reason being that statements like c and false or true always returns true, while the boxed statement (c and {false} or {true})[1] works as expected. Alternatively, when there are no side effects we could use a lib call such as libTernary(c, true, false). However, in my benchmarks this is still equal or slower than the more general IIFE approach on lua5.3 or boxing in luajit. Feel free to try it yourself: https://gist.github.com/TheLartians/0b8bbdc551b67b7205e347df7525c205 .
In the long run, we can probably introduce temporary variables and avoid the ternary statement altogether, however this requires AST transformations.

@DoctorGester
Copy link
Copy Markdown
Contributor

I understand the problem, however most cases could be handled with the usual lua ternary, I don't think settling for a general solution which is in many cases worse than a specific one is a good idea. Since it's typescript we should assume the type is known and understand what's a boolean/nullable type and what isn't. And yeah a temporary variable would be preferable in other cases

@tomblind
Copy link
Copy Markdown
Collaborator

One thing to keep in mind is that without strict null checks enabled, any type could be nullable.

@DoctorGester
Copy link
Copy Markdown
Contributor

You can always go for the most defensive version when type information is unreliable.

@TheLartians
Copy link
Copy Markdown
Contributor Author

I've added a isNonFalsible helper function that currently returns true when the type is a non-boolean literal. In that case the ternary is transpiled without protecting boxes or closures.
Are there more cases we can easily check that are guaranteed to be non-falsible?

const falsibleFlags = ts.TypeFlags.Boolean
| ts.TypeFlags.BooleanLiteral
| ts.TypeFlags.Undefined
| ts.TypeFlags.Any;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Will this catch null-able types?

declare function foo(): string | null;
const x = condition ? foo() : "bar";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Currently only literal types are allowed to be non-falsible as then we know the exact value of the expression. As we already excluded undefined and boolean literals before we now know that whenTrue must always be a valid non falsible expression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: now the above only applies when strictNullChecks are not enabled. Otherwise I recursively iterate through all union types and check if they are all non-falsible.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reading this I realize why I got confused. Will a null literal get caught by those flags?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, I wasn't aware that there is a difference between null and undefined. I added an additional check for it.

return this.transpileLuaLibFunction(LuaLibFeature.Ternary, condition,
`function() return ${val1} end`, `function() return ${val2} end`);
public transpileConditionalExpression(node: ts.ConditionalExpression): string {
if (tsHelper.isNonFalsible(this.checker.getTypeAtLocation(node.whenTrue))) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What about if strictNullChecks is off? Shouldn't the protected version always be used in this case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Currently we are playing it safe and always protecting non-literal expressions. With strictNullChecks enabled we could also allow non-literal types to be transpiled without protection.
It should be trivial to extend isNonFalsible to support strictNullChecks in the future.

Copy link
Copy Markdown
Contributor Author

@TheLartians TheLartians Jan 4, 2019

Choose a reason for hiding this comment

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

Update: the future is now.

src/TSHelper.ts Outdated
return defaultArrayPropertyNames.has(methodName);
}

public static isNonFalsible(type: ts.Type, strictNullChecks: boolean): boolean {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

IsNonFalsible is a pretty confusing name - perhaps it should be reversed to be IsFalsible and checked with a not "!" operator later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fine by me.

return this.transpileLuaLibFunction(LuaLibFeature.Ternary, condition,
`function() return ${val1} end`, `function() return ${val2} end`);
public transpileConditionalExpression(node: ts.ConditionalExpression): string {
if (tsHelper.isFalsible(this.checker.getTypeAtLocation(node.whenTrue), this.options.strictNullChecks)) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One more thing (sorry): "strictNullChecks" is implicitly enabled if "strict" is set in options, so you'll want to check for that too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Alright.

Copy link
Copy Markdown
Collaborator

@tomblind tomblind left a comment

Choose a reason for hiding this comment

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

Looks great - I think this is a really nice solution to handling ternary operations 😄

@tomblind tomblind merged commit e9ce425 into TypeScriptToLua:master Jan 17, 2019
@TheLartians TheLartians deleted the simplified-ternary branch January 17, 2019 16:19
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.

4 participants