Skip to content

GDScript: Improve if statement performance when using and/or operators#120660

Open
aurpine wants to merge 4 commits into
godotengine:masterfrom
aurpine:improve-if-statement-logical-binary-op
Open

GDScript: Improve if statement performance when using and/or operators#120660
aurpine wants to merge 4 commits into
godotengine:masterfrom
aurpine:improve-if-statement-logical-binary-op

Conversation

@aurpine

@aurpine aurpine commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Overview

Improves GDScript if statement performance when and and or operators are used at the top level (includes nesting). Performance testing shows up to 2-3x speed-up in the most basic case. Note this does not account for condition calculation times. Real-world performance gains are likely lower.

The change optimizes the compiler bytecode generation to remove a boolean assignment, and a conditional jump per operator used.

Problem

The compiler treats expressions as a black box when used in a conditional statement. Every logical operator assigns its result to a temporary variable which is then used by branching (or other operators!). Consider the simple statement

if a and b:
    pass

This compiles into the following bytecode.

 0: line 2:    if a and b:
 2: jump-if-not stack(3) to 12
 5: jump-if-not stack(4) to 12
 8: assign stack(5) = true
 10: jump 14
 12: assign stack(5) = false
 14: jump-if-not stack(5) to 21
 17: assign stack(5) = null
 19: line 3:           pass
 21: == END ==

Say if we were to decompile this, it currently looks like it came from the following

var c = a and b
if c:
    pass

Instead, we should try to make it look like it came from

if a:
    if b:
        pass

Solution

Instead of jumping to set a variable, we will directly jump to either the if branch or else branch (if applicable).

With the fix applied, the generated bytecode for the previous example is

 0: line 2:    if a and b:
 2: jump-if-not stack(3) to 10
 5: jump-if-not stack(4) to 10
 8: line 3:            pass
 10: == END ==

The bytecode is 11 slots less for every operator used. We saved a net one conditional jump and one assignment (possibly two with the cleanup).

Implementation

We maintain two stacks of jumps for success and failure. These may be partially patched when the operator needs to do additional checks. Otherwise, some are left to the caller to patch directly to the if/else branch.

  • gdscript_byte_codegen contains the helpers to write the bytecode. These are pretty low level jumps.
  • gdscript_compiler this is where the new _parse_expr_cond function uses the new helpers to create and set the jumps.

Explanation on the helpers:

function description
start_expr_cond_buffer Keeps track of the number of jumps so that all new jumps can be patched later on.
flush_expr_cond_buffer Patches all the jumps since calling start_expr_cond_buffer to the current position.
write_expr_cond_jump_if Writes a conditional jump. The jump direction is determined by the destination type (if/else).
write_expr_cond_jump Writes an unconditional jump. This is used in fallthrough scenarios.
write_expr_cond_jump_end Writes a jump to the "end" of the block (similar to write_else).
write_expr_cond_end Patches the jump to the end (similar to write_endif)

Benchmarks

To keep the benchmark simple only two cases are run with both boolean values set to false. The runtime scales with the number of necessary jump checks.

Scenario A (one check)

if a and b:
    pass

Scenario B (two checks)

if a or b:
    pass
Testing code
func _on_and_button_pressed() -> void:
	var a: bool = false
	var b: bool = false
	
	var t = Time.get_ticks_msec()
	
	for i in 100_000_000:
		if a and b:
			pass
	
	print((Time.get_ticks_msec() - t) / 1000.)

func _on_or_button_pressed() -> void:
	var a: bool = false
	var b: bool = false
	
	var t = Time.get_ticks_msec()
	
	for i in 100_000_000:
		if a or b:
			pass
	
	print((Time.get_ticks_msec() - t) / 1000.)

All tests are run on Windows 11, release templates. PR is built with scons platform=windows target=template_release lto=full production=yes use_mingw=yes.

The scenarios are run in a loop with N=100_000_000. Run 10 times and averaged, with one run discarded as warm up. Due to the speed of if statements, the loop overhead is significant. Subtracting the loop overhead gives us a more accurate glimpse of the performance improvement.

Scenario PR Master Delta
A 0.399s 0.821s -51%
B 0.589s 0.937s -37%
E (empty loop) 0.208s 0.208s -
A - E 0.191s 0.613s -69%
B - E 0.381s 0.729s -48%

Future Work

We can add the changes to the following statements:

  • while
  • match

The changes can be extended to the following expression types:

  • ternary x if cond else y
  • logical not
  • binary comparison operators ==, !=, >, >=, <, <=

@aurpine aurpine marked this pull request as ready for review June 29, 2026 05:33
@aurpine aurpine requested review from a team as code owners June 29, 2026 05:33
@aurpine aurpine changed the title GDScript: Improve IF statement performance when using logical and/or expressions GDScript: Improve if statement performance when using and/or operators Jun 29, 2026
Comment thread modules/gdscript/gdscript_codegen.h Outdated
Comment thread modules/gdscript/gdscript_compiler.cpp Outdated
Comment on lines +2104 to +2105
gen->start_expr_cond_buffer(false);
gen->start_expr_cond_buffer(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is there a false followed by a true call here?

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.

For the if and else branches. This allows us to patch both jumps. The true jumps go to the if block and the false jumps go to the else or the end if not supplied.

Any statement will call both start buffers. and and or do not call both because some cases are left to this call-site to patch (essentially a short-cut).

virtual void write_start(GDScript *p_script, const StringName &p_function_name, bool p_static, Variant p_rpc_config, const GDScriptDataType &p_return_type) override;
virtual GDScriptFunction *write_end() override;

virtual void start_expr_cond_buffer(bool success) override;

@Shadows-of-Fire Shadows-of-Fire Jul 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We might want to consider using a two-value enum here instead of bool success. This allows calls to be more representable out of context. Consider:

start_expr_cond_buffer(BranchType.TAKEN)

vs

start_expr_cond_buffer(true)

This also avoids needing to document the meaning of bool success in multiple places.

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.

That makes sense. Could also do another state for both - though it's only used in that one case.

In the future we may need to flip these when implementing not. Not that it's a problem for the enums though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants