Skip to content

Commit 870877c

Browse files
committed
Add step-matching benchmark
Adds a minimal, dependency-free benchmark harness for step_finder's matching path: registers ~50 representative step definitions and times many step_finder::find() lookups against a mix of matching and non-matching feature-step strings, using the existing cuke::internal::execute_and_count_time() timer helper. Intended to quantify the effect of changes to step_finder/ step_definition matching (e.g. the compiled-regex caching added in #136) and catch future regressions, rather than as a precise statistical microbenchmark. New CUCUMBER_BUILD_TESTS_AND_EXAMPLES-gated target: ./build/bin/step-matching-benchmark [iterations] Documented in AGENTS.md alongside the existing test-running docs.
1 parent 703dc17 commit 870877c

5 files changed

Lines changed: 193 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,26 @@ cmake --build ./build -j$(nproc)
4848
./build/bin/example ./examples --exclude-file 11_manual_fails.feature
4949
```
5050

51+
### Benchmarks
52+
53+
```sh
54+
# Step-matching throughput benchmark (registers ~50 step definitions,
55+
# runs many step_finder::find() lookups, reports avg time per lookup)
56+
./build/bin/step-matching-benchmark # default: 100000 iterations
57+
./build/bin/step-matching-benchmark 1000000 # custom iteration count
58+
```
59+
60+
Use this to quantify the effect of changes to `step_finder`/`step_definition`
61+
matching (e.g. before/after a regex-caching or matcher-backend change).
62+
It is a plain, dependency-free timer harness (`cuke::internal::execute_and_count_time`),
63+
not a statistical microbenchmarking framework — treat results as a rough
64+
relative signal, not an absolute number to publish.
65+
5166
### CMake build options
5267

5368
| Option | Default | Effect |
5469
|---|---|---|
55-
| `CUCUMBER_BUILD_TESTS_AND_EXAMPLES` | `ON` | Build `unittests`, `example`, `stress-tests` targets |
70+
| `CUCUMBER_BUILD_TESTS_AND_EXAMPLES` | `ON` | Build `unittests`, `example`, `stress-tests`, `step-matching-benchmark` targets |
5671
| `CUCUMBER_UNDEFINED_STEPS_ARE_A_FAILURE` | `ON` | Final result is FAILED if any step has no definition. Set `OFF` in CI and agent runs |
5772

5873
---
@@ -97,6 +112,9 @@ cwt-cucumber/
97112
│ ├── step_definition.cpp # Example step implementations
98113
│ ├── custom_parameters.cpp
99114
│ └── hooks.cpp
115+
├── benchmarks/
116+
│ ├── step_matching_benchmark.cpp # step_finder::find() throughput harness
117+
│ └── CMakeLists.txt
100118
├── .github/workflows/
101119
│ ├── copilot-setup-steps.yml # Pre-builds env for Copilot cloud agent
102120
│ └── unittests.yml # CI: Linux (GCC 13 + Clang 17), Windows, macOS
@@ -796,12 +814,23 @@ From `.clang-format`:
796814
- `SortIncludes: false` — keep include order as written
797815
- Max line length: 80 (Google default)
798816

799-
Run before committing:
817+
**Always run `clang-format` on changed/added files before committing**
818+
CI runs a format check on the diff and will reject PRs that fail it (this
819+
is the most common avoidable CI failure). Format only the files you
820+
touched, relative to the branch's merge base with `main`:
821+
800822
```sh
801-
clang-format -i src/*.hpp src/*.cpp gtest/*.cc
823+
# Format every changed/added .cpp/.hpp/.cc file vs. main
824+
git diff --name-only --diff-filter=d main... -- '*.cpp' '*.hpp' '*.cc' \
825+
| xargs -r clang-format -i
826+
827+
# Re-check nothing is left unformatted
828+
git diff --name-only --diff-filter=d main... -- '*.cpp' '*.hpp' '*.cc' \
829+
| xargs -r clang-format --dry-run --Werror
802830
```
803831

804-
CI will reject PRs that fail the format check.
832+
(For a single file you're actively editing, `clang-format -i path/to/file`
833+
is sufficient.)
805834

806835
Additional conventions observed in the codebase:
807836

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ if(CUCUMBER_BUILD_TESTS_AND_EXAMPLES)
2828
add_subdirectory(${PROJECT_SOURCE_DIR}/examples)
2929
add_subdirectory(${PROJECT_SOURCE_DIR}/gtest)
3030
add_subdirectory(${PROJECT_SOURCE_DIR}/stress-tests)
31+
add_subdirectory(${PROJECT_SOURCE_DIR}/benchmarks)
3132
endif()

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ cmake --build ./build -j$(nproc)
3232
./build/bin/example ./examples --exclude-file 11_manual_fails.feature
3333
```
3434

35+
## Benchmarking step matching
36+
37+
If you change `step_finder`/`step_definition` matching logic, run this
38+
before and after your change on the same machine to sanity-check the
39+
performance impact (not a precise, cross-platform benchmark — see
40+
[Benchmarks](AGENTS.md#benchmarks) in AGENTS.md for details):
41+
42+
```sh
43+
./build/bin/step-matching-benchmark
44+
```
45+
3546
---
3647

3748
## Making changes

benchmarks/CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
set(target step-matching-benchmark)
2+
3+
add_executable(${target} ${CMAKE_CURRENT_SOURCE_DIR}/step_matching_benchmark.cpp)
4+
5+
target_include_directories(${target} PRIVATE ${PROJECT_SOURCE_DIR}/src)
6+
target_link_libraries(${target} cucumber-no-main)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Minimal, dependency-free benchmark for step matching.
2+
//
3+
// Measures how long it takes to find a matching step_definition for a
4+
// batch of feature-step strings against a realistic set of registered
5+
// steps, using the same step_finder::find() path the interpreter uses
6+
// at runtime. Run it before/after a change to step_finder/step to see
7+
// its effect on matching throughput.
8+
//
9+
// Usage: ./build/bin/step-matching-benchmark [iterations]
10+
11+
#include <cstdlib>
12+
#include <iomanip>
13+
#include <iostream>
14+
#include <stdexcept>
15+
#include <string>
16+
#include <vector>
17+
18+
#include "step.hpp"
19+
#include "step_finder.hpp"
20+
#include "util.hpp"
21+
22+
namespace
23+
{
24+
25+
void noop_callback(const cuke::value_array&,
26+
const std::vector<cuke::internal::param_info>&,
27+
const cuke::internal::doc_string&, const cuke::table&)
28+
{
29+
}
30+
31+
std::vector<cuke::internal::step_definition> make_step_definitions()
32+
{
33+
std::vector<cuke::internal::step_definition> steps;
34+
steps.reserve(50);
35+
36+
steps.emplace_back(noop_callback, "I have {int} cukes in my belly");
37+
steps.emplace_back(noop_callback, "I eat {int} cukes");
38+
steps.emplace_back(noop_callback, "I place {int} x {string} in it");
39+
steps.emplace_back(noop_callback, "the stock count should be {int}");
40+
steps.emplace_back(noop_callback, "I see {} in the raw output");
41+
steps.emplace_back(noop_callback, "The value is {word}");
42+
steps.emplace_back(noop_callback, "It should equal {string}");
43+
steps.emplace_back(noop_callback, "the shipping label should equal {string}");
44+
steps.emplace_back(noop_callback,
45+
"The box gets shipped at {int}-{int}-{int}");
46+
steps.emplace_back(noop_callback, "I have a doc string:");
47+
48+
// Pad with additional, syntactically distinct steps so a realistic
49+
// step_finder::find() has to walk a non-trivial registry, similar to
50+
// a medium-sized real project.
51+
for (int i = 0; i < 40; ++i)
52+
{
53+
steps.emplace_back(
54+
noop_callback,
55+
std::format(
56+
"this is generated step number {} with {{int}} and {{string}}", i));
57+
}
58+
59+
return steps;
60+
}
61+
62+
std::vector<std::string> make_feature_steps()
63+
{
64+
return {
65+
"I have 5 cukes in my belly",
66+
"I eat 3 cukes",
67+
"I place 2 x \"widget\" in it",
68+
"the stock count should be 42",
69+
"I see {status} in the raw output",
70+
"The value is foo/bar",
71+
"It should equal \"foo/bar\"",
72+
"the shipping label should equal \"Ship to: Tokyo, Japan\"",
73+
"The box gets shipped at 2026-08-27",
74+
"I have a doc string:",
75+
"this step definitely does not match anything",
76+
};
77+
}
78+
79+
} // namespace
80+
81+
int main(int argc, char** argv)
82+
{
83+
std::size_t iterations = 100000;
84+
if (argc > 1)
85+
{
86+
try
87+
{
88+
const unsigned long parsed = std::stoul(argv[1]);
89+
if (parsed == 0)
90+
{
91+
throw std::invalid_argument("iterations must be positive");
92+
}
93+
iterations = parsed;
94+
}
95+
catch (const std::exception&)
96+
{
97+
std::cerr << "usage: " << argv[0]
98+
<< " [iterations] (must be a positive integer)\n";
99+
return 1;
100+
}
101+
}
102+
103+
const std::vector<cuke::internal::step_definition> steps =
104+
make_step_definitions();
105+
const std::vector<std::string> feature_steps = make_feature_steps();
106+
107+
std::size_t matched = 0;
108+
std::size_t unmatched = 0;
109+
110+
const double seconds = cuke::internal::execute_and_count_time(
111+
[&]()
112+
{
113+
for (std::size_t i = 0; i < iterations; ++i)
114+
{
115+
const std::string& feature_step =
116+
feature_steps[i % feature_steps.size()];
117+
cuke::internal::step_finder finder(feature_step);
118+
auto it = finder.find(steps.begin(), steps.end());
119+
if (it != steps.end())
120+
{
121+
++matched;
122+
}
123+
else
124+
{
125+
++unmatched;
126+
}
127+
}
128+
});
129+
130+
const double per_match_us =
131+
(seconds * 1'000'000.0) / static_cast<double>(iterations);
132+
133+
std::cout << std::fixed << std::setprecision(3);
134+
std::cout << "step definitions registered : " << steps.size() << '\n';
135+
std::cout << "feature step lookups : " << iterations << '\n';
136+
std::cout << "matched : " << matched << '\n';
137+
std::cout << "unmatched : " << unmatched << '\n';
138+
std::cout << "total time : " << seconds << " s\n";
139+
std::cout << "avg time per lookup : " << per_match_us << " us\n";
140+
141+
return 0;
142+
}

0 commit comments

Comments
 (0)