Skip to content

Commit 97ec4e8

Browse files
committed
catch_discover_tests: Escape test-invariant parts of CTest script only once
Previously, the `catch_discover_tests` would prepare the entire CTest command (e.g. `add_test(...)` or `set_tests_properties(...)`) first, and then escape it when finished. However, this caused lot of the command args to be escaped over and over again (e.g. executable name or Catch2's reporter args), for no reason, as they were always the same, and thus their escaping was always the same. Until recently, the performance overhead didn't matter as there were many spots which had quadratic runtime in number of tests. However, the recent refactorings fixed these, and this commit now improves the throughput by 10-20%. Also extended the benchmarked COUNTS in `benchmark_discovery.py`, because the performance is now good enough that it is reasonable to benchmark 16k tests.
1 parent 0136276 commit 97ec4e8

4 files changed

Lines changed: 80 additions & 66 deletions

File tree

benchmarks/discover_tests/benchmark_discovery.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
HERE = os.path.dirname(os.path.abspath(__file__))
2222
TEMPLATE = os.path.join(HERE, "listing_template.json")
2323
SHIM = os.path.join(HERE, "copy_shim.cmake")
24-
COUNTS = [1, 10, 500, 1000, 2000, 4000, 8000]
24+
COUNTS = [1, 10, 500, 1000, 2000, 4000, 8000, 16000]
2525
#COUNTS = [1, 10, 100]
2626
REPEATS = 5
2727

extras/CatchAddTests.cmake

Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -140,35 +140,28 @@ function(split_json_array json_array_var out_var)
140140
set(${out_var} "${array_elements}" PARENT_SCOPE)
141141
endfunction()
142142

143-
# TBD: Further possible optimization is that most arguments for per-test
144-
# `prepare_command` call are constant across one invocation of
145-
# `catch_discover_tests`, and thus need checking and escaping only
146-
# once, instead of for each test.
147-
# This would provide nice speed-up of the actual command preparation,
148-
# but it will make the script much harder to read, and it is utterly
149-
# dwarfed by the quadratic scaling of parsing JSON arrays in CMake.
150143

151-
152-
# Prepare command with escaped (bracketed) arguments and return it via `_Command` out variable.
144+
# Prepare (a part of) command with bracketed arguments and return it via `out_var`.
153145
#
154-
# To avoid quadratic performance when concatenating all commands together,
155-
# the actual concatenation must be done by the caller, by appending it
156-
# into a string of all other commands.
157-
function(prepare_command NAME)
146+
# This allows the registration script to escape parts of the test script
147+
# only once, instead of escaping the unchanged arguments over and over again.
148+
function(prepare_command_fragment out_var)
158149
set(_args "")
159-
# use ARGV* instead of ARGN, because ARGN splits arrays into multiple arguments
160150
math(EXPR _last_arg ${ARGC}-1)
161-
foreach(_n RANGE 1 ${_last_arg})
162-
set(_arg "${ARGV${_n}}")
163-
if(_arg MATCHES "[^-./:a-zA-Z0-9_]")
164-
set(_args "${_args} [==[${_arg}]==]") # form a bracket_argument
165-
else()
166-
set(_args "${_args} ${_arg}")
167-
endif()
168-
endforeach()
169-
set(_Command "${NAME}(${_args})\n" PARENT_SCOPE)
151+
if(_last_arg GREATER_EQUAL 1)
152+
foreach(_n RANGE 1 ${_last_arg})
153+
set(_arg "${ARGV${_n}}")
154+
if(_arg MATCHES "[^-./:a-zA-Z0-9_]")
155+
set(_args "${_args} [==[${_arg}]==]") # form a bracket_argument
156+
else()
157+
set(_args "${_args} ${_arg}")
158+
endif()
159+
endforeach()
160+
endif()
161+
set(${out_var} "${_args}" PARENT_SCOPE)
170162
endfunction()
171163

164+
172165
# Generates random filename in the temp folder.
173166
# Temp folder is retrieved by checking env vars from various platforms.
174167
function(make_temp_file_path OUT_VARIABLE FALLBACK_PATH)
@@ -395,6 +388,41 @@ function(catch_discover_tests_impl)
395388
# so that each test name can be appended file without further processing.
396389
set(test_names "set(${_TEST_LIST}")
397390

391+
# Most of the commands/arguments in the CTest script are identical
392+
# for every test registered with one `catch_discover_tests` call.
393+
# To avoid repeating the work in escaping them, we escape them before
394+
# the per-test loop and reuse the escaped fragments.
395+
#
396+
# `add_test` calls are
397+
# add_test(<name><exec><exe><escaped_name><extra_args><reporter><out_dir>)
398+
# Of these, <exec>,<exe>,<extra_args>, and <reporter> are the same between
399+
# all tests.
400+
prepare_command_fragment(_exec_exe_fragment
401+
${_TEST_EXECUTOR}
402+
"${_TEST_EXECUTABLE}"
403+
)
404+
prepare_command_fragment(_args_reporter_fragment
405+
${extra_args}
406+
"${reporter_arg}"
407+
)
408+
409+
# `set_tests_properties` calls are
410+
# set_tests_properties(<name> PROPERTIES WORKING_DIRECTORY <dir> <properties>)
411+
# set_tests_properties(<name> PROPERTIES ENVIRONMENT_MODIFICATION <env_mod>)
412+
# Of these, only the <name> changes between tests.
413+
prepare_command_fragment(_properties_fragment
414+
PROPERTIES
415+
WORKING_DIRECTORY "${_TEST_WORKING_DIR}"
416+
${properties}
417+
)
418+
# Env modification is optional, so we prepare it in a separate command
419+
if(environment_modifications)
420+
prepare_command_fragment(_env_modification_fragment
421+
PROPERTIES
422+
ENVIRONMENT_MODIFICATION "${environment_modifications}"
423+
)
424+
endif()
425+
398426
# Each element in the tests is JSON-string representing one test object.
399427
# We have to parse it and then turn it into CTest script commands.
400428
foreach(single_test IN LISTS tests)
@@ -431,24 +459,15 @@ function(catch_discover_tests_impl)
431459
set(output_dir_arg "--out ${output_dir}/${output_prefix}${escaped_name_clean}${output_suffix}")
432460
endif()
433461

434-
# ...and add to script
435-
prepare_command(add_test
436-
"${prefix}${plain_name}${suffix}"
437-
${_TEST_EXECUTOR}
438-
"${_TEST_EXECUTABLE}"
439-
"${escaped_name}"
440-
${extra_args}
441-
"${reporter_arg}"
442-
"${output_dir_arg}"
443-
)
444-
string(APPEND script "${_Command}")
445-
prepare_command(set_tests_properties
446-
"${prefix}${plain_name}${suffix}"
447-
PROPERTIES
448-
WORKING_DIRECTORY "${_TEST_WORKING_DIR}"
449-
${properties}
450-
)
451-
string(APPEND script "${_Command}")
462+
set(full_name "${prefix}${plain_name}${suffix}")
463+
prepare_command_fragment(_full_name_fragment "${full_name}")
464+
prepare_command_fragment(_escaped_name_fragment "${escaped_name}")
465+
prepare_command_fragment(_outdir_fragment "${output_dir_arg}")
466+
467+
string(APPEND script
468+
"add_test(${_full_name_fragment}${_exec_exe_fragment}${_escaped_name_fragment}${_args_reporter_fragment}${_outdir_fragment})\n")
469+
string(APPEND script
470+
"set_tests_properties(${_full_name_fragment}${_properties_fragment})\n")
452471

453472
if(add_tags)
454473
string(JSON num_tags LENGTH "${test_tags}")
@@ -468,21 +487,16 @@ function(catch_discover_tests_impl)
468487
list(APPEND tag_list "${a_tag}")
469488
endforeach()
470489

471-
prepare_command(set_tests_properties
472-
"${prefix}${plain_name}${suffix}"
490+
prepare_command_fragment(_labels_fragment
473491
PROPERTIES
474492
LABELS "${tag_list}"
475493
)
476-
string(APPEND script "${_Command}")
494+
string(APPEND script "set_tests_properties(${_full_name_fragment}${_labels_fragment})\n")
477495
endif()
478496
endif(add_tags)
479497

480498
if(environment_modifications)
481-
prepare_command(set_tests_properties
482-
"${prefix}${plain_name}${suffix}"
483-
PROPERTIES
484-
ENVIRONMENT_MODIFICATION "${environment_modifications}")
485-
string(APPEND script "${_Command}")
499+
string(APPEND script "set_tests_properties(${_full_name_fragment}${_env_modification_fragment})\n")
486500
endif()
487501

488502
# The test name has to be escaped using the same rules as prepare_command

tests/CMakeLists.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -672,11 +672,11 @@ if(CATCH_ENABLE_CMAKE_HELPER_TESTS)
672672
LABELS "uses-python"
673673
)
674674

675-
add_test(NAME "CMakeHelper::PrepareCommand"
675+
add_test(NAME "CMakeHelper::PrepareCommandFragment"
676676
COMMAND
677677
"${CMAKE_COMMAND}"
678678
"-DCATCH_ADD_TESTS_SCRIPT=${CATCH_DIR}/extras/CatchAddTests.cmake"
679-
-P "${CMAKE_CURRENT_LIST_DIR}/TestScripts/DiscoverTests/TestPrepareCommand.cmake"
679+
-P "${CMAKE_CURRENT_LIST_DIR}/TestScripts/DiscoverTests/TestPrepareCommandFragment.cmake"
680680
)
681681

682682
add_test(NAME "CMakeHelper::DecomposeJsonArray"

tests/TestScripts/DiscoverTests/TestPrepareCommand.cmake renamed to tests/TestScripts/DiscoverTests/TestPrepareCommandFragment.cmake

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# SPDX-License-Identifier: BSL-1.0
22

3-
# Unit tests for `prepare_command` helper in `extras/CatchAddTests.cmake`.
3+
# Unit tests for `prepare_command_fragment` helper in `extras/CatchAddTests.cmake`.
44
#
55
# Yes, we are at the stage where the script helpers need unit tests.
66
#
77
# Run as
88
# cmake -DCATCH_ADD_TESTS_SCRIPT=/path/to/extras/CatchAddTests.cmake \
9-
# -P TestPrepareCommand.cmake
9+
# -P TestPrepareCommandFragment.cmake
1010

1111

1212
cmake_minimum_required(VERSION 3.19)
@@ -37,25 +37,25 @@ function(expect_equal description actual expected)
3737
endif()
3838
endfunction()
3939

40-
prepare_command(add_test SimpleName /path/to/tests)
41-
expect_equal("Simple arg, no quotes" "${_Command}" "add_test( SimpleName /path/to/tests)\n")
40+
prepare_command_fragment(test_fragment SimpleName /path/to/tests)
41+
expect_equal("Simple arg, no quotes" "${test_fragment}" " SimpleName /path/to/tests")
4242

43-
prepare_command(add_test "Name with spaces")
44-
expect_equal("Spaces in arg, needs quotes" "${_Command}" "add_test( [==[Name with spaces]==])\n")
43+
prepare_command_fragment(test_fragment "Name with spaces")
44+
expect_equal("Spaces in arg, needs quotes" "${test_fragment}" " [==[Name with spaces]==]")
4545

46-
prepare_command(set_tests_properties Foo PROPERTIES LABELS "tagA\;tagB\;tagC")
46+
prepare_command_fragment(test_fragment Foo PROPERTIES LABELS "tagA\;tagB\;tagC")
4747
expect_equal("semicolons in argument are kept and quoted"
48-
"${_Command}" "set_tests_properties( Foo PROPERTIES LABELS [==[tagA\;tagB\;tagC]==])\n")
48+
"${test_fragment}" " Foo PROPERTIES LABELS [==[tagA\;tagB\;tagC]==]")
4949

50-
set(_Command "PRE-EXISTING")
51-
prepare_command(set_tests_properties Foo PROPERTIES BAR baz)
52-
expect_equal("_Command var does no accumulate commands"
53-
"${_Command}" "set_tests_properties( Foo PROPERTIES BAR baz)\n")
50+
set(test_fragment "PRE-EXISTING")
51+
prepare_command_fragment(test_fragment Foo PROPERTIES BAR baz)
52+
expect_equal("out var does no accumulate commands"
53+
"${test_fragment}" " Foo PROPERTIES BAR baz")
5454

5555

5656

5757
if(_failures GREATER 0)
58-
message(FATAL_ERROR "${_failures} prepare_command test(s) failed")
58+
message(FATAL_ERROR "${_failures} prepare_command_fragment test(s) failed")
5959
else()
60-
message(STATUS "All prepare_command tests passed")
60+
message(STATUS "All prepare_command_fragment tests passed")
6161
endif()

0 commit comments

Comments
 (0)