Skip to content

Commit d83d02a

Browse files
committed
ci: balance CLI shards by duration
1 parent 140aea7 commit d83d02a

3 files changed

Lines changed: 267 additions & 10 deletions

File tree

scripts/plan-test-shards.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import csv
6+
import statistics
7+
import sys
8+
from dataclasses import dataclass
9+
from pathlib import Path
10+
11+
12+
@dataclass(frozen=True)
13+
class TestEstimate:
14+
name: str
15+
duration_seconds: float
16+
17+
18+
def read_tests(path: Path) -> tuple[str, ...]:
19+
return tuple(
20+
line.strip()
21+
for line in path.read_text(encoding="utf-8").splitlines()
22+
if line.strip()
23+
)
24+
25+
26+
def read_timings(path: Path) -> dict[str, float]:
27+
if not path.exists():
28+
return {}
29+
30+
with path.open(encoding="utf-8", newline="") as stream:
31+
return {
32+
row["test_name"]: float(row["duration_seconds"])
33+
for row in csv.DictReader(stream, delimiter="\t")
34+
}
35+
36+
37+
def plan_shards(
38+
tests: tuple[str, ...],
39+
timings: dict[str, float],
40+
shard_count: int,
41+
) -> tuple[tuple[int, TestEstimate], ...]:
42+
known_durations = [timings[name] for name in tests if name in timings]
43+
fallback_duration = statistics.median(known_durations) if known_durations else 1.0
44+
estimates = [
45+
TestEstimate(name=name, duration_seconds=timings.get(name, fallback_duration))
46+
for name in tests
47+
]
48+
estimates.sort(key=lambda test: (-test.duration_seconds, test.name))
49+
50+
loads = [0.0] * shard_count
51+
counts = [0] * shard_count
52+
assignments = []
53+
for estimate in estimates:
54+
shard = min(range(shard_count), key=lambda index: (loads[index], counts[index], index))
55+
assignments.append((shard, estimate))
56+
loads[shard] += estimate.duration_seconds
57+
counts[shard] += 1
58+
59+
return tuple(sorted(assignments, key=lambda assignment: (assignment[0], assignment[1].name)))
60+
61+
62+
def write_plan(path: Path, assignments: tuple[tuple[int, TestEstimate], ...]) -> None:
63+
with path.open("w", encoding="utf-8", newline="") as stream:
64+
writer = csv.writer(stream, delimiter="\t", lineterminator="\n")
65+
writer.writerow(("shard_index", "estimated_seconds", "test_name"))
66+
for shard, estimate in assignments:
67+
writer.writerow((shard, f"{estimate.duration_seconds:.6f}", estimate.name))
68+
69+
70+
def main() -> None:
71+
if len(sys.argv) != 5:
72+
raise SystemExit(
73+
"Usage: plan-test-shards.py <tests.txt> <timings.tsv> <shard-count> <output.tsv>"
74+
)
75+
76+
tests_path = Path(sys.argv[1])
77+
timings_path = Path(sys.argv[2])
78+
shard_count = int(sys.argv[3])
79+
output_path = Path(sys.argv[4])
80+
if shard_count <= 0:
81+
raise SystemExit("Shard count must be positive.")
82+
83+
tests = read_tests(tests_path)
84+
if not tests:
85+
raise SystemExit("At least one test is required.")
86+
if len(set(tests)) != len(tests):
87+
raise SystemExit("Test names must be unique.")
88+
89+
assignments = plan_shards(tests, read_timings(timings_path), shard_count)
90+
write_plan(output_path, assignments)
91+
92+
93+
if __name__ == "__main__":
94+
main()

scripts/run-test-shard.sh

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ shard_count=$3
1313
configuration=${4:-Release}
1414
artifacts_root=${TEST_SHARD_ARTIFACTS_DIR:-artifacts/test-shards}
1515
shard_artifacts="$artifacts_root/shard-$shard_index"
16+
timings_file=${TEST_SHARD_TIMINGS_FILE:-"$(dirname "$project")/test-timings.tsv"}
1617

1718
if ! [[ $shard_index =~ ^[0-9]+$ && $shard_count =~ ^[1-9][0-9]*$ ]]; then
1819
echo "Shard index and count must be non-negative integers, with a positive count." >&2
@@ -57,26 +58,35 @@ if [[ -n $duplicate_names ]]; then
5758
exit 1
5859
fi
5960

61+
mkdir -p "$shard_artifacts"
62+
rm -f \
63+
"$shard_artifacts/discovered-tests.txt" \
64+
"$shard_artifacts/membership.txt" \
65+
"$shard_artifacts/metadata.tsv" \
66+
"$shard_artifacts/plan.tsv" \
67+
"$shard_artifacts/results.trx"
68+
printf '%s\n' "${tests[@]}" > "$shard_artifacts/discovered-tests.txt"
69+
python3 scripts/plan-test-shards.py \
70+
"$shard_artifacts/discovered-tests.txt" \
71+
"$timings_file" \
72+
"$shard_count" \
73+
"$shard_artifacts/plan.tsv"
74+
6075
filters=()
6176
selected_tests=()
62-
for test_index in "${!tests[@]}"; do
63-
if (( test_index % shard_count == shard_index )); then
64-
selected_tests+=("${tests[$test_index]}")
65-
filters+=("Name=${tests[$test_index]}")
77+
while IFS=$'\t' read -r planned_shard _ test_name; do
78+
if [[ $planned_shard == "$shard_index" ]]; then
79+
selected_tests+=("$test_name")
80+
filters+=("Name=$test_name")
6681
fi
67-
done
82+
done < <(sed '1d' "$shard_artifacts/plan.tsv")
6883

6984
if (( ${#filters[@]} == 0 )); then
7085
echo "Shard $shard_index has no tests." >&2
7186
exit 1
7287
fi
7388

7489
filter=$(IFS='|'; printf '%s' "${filters[*]}")
75-
mkdir -p "$shard_artifacts"
76-
rm -f \
77-
"$shard_artifacts/membership.txt" \
78-
"$shard_artifacts/metadata.tsv" \
79-
"$shard_artifacts/results.trx"
8090
printf '%s\n' "${selected_tests[@]}" > "$shard_artifacts/membership.txt"
8191
printf 'key\tvalue\nproject\t%s\nshard_index\t%s\nshard_count\t%s\ndiscovered_tests\t%s\nselected_tests\t%s\n' \
8292
"$project" \
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
test_name duration_seconds
2+
CliProject_GeneratesPackAsToolApiCommands_AndBuilds 56.338681
3+
CliProject_RespectsMethodNamingConvention 24.345590
4+
Cli_ProtoInput_ShowsGrpcNotSupportedMessage 9.491544
5+
Generate_BlackForestLabsSdk 13.765671
6+
Generate_BlandAISdk 13.586970
7+
Generate_BraintrustSdk 43.345768
8+
Generate_BraveSearchSdk 12.709234
9+
Generate_BrowserbaseSdk 12.396770
10+
Generate_BufModuleInput_ScaffoldsGrpcClientProject 8.907099
11+
Generate_CartesiaSdk 21.886657
12+
Generate_ChromaSdk 14.632093
13+
Generate_CohereSdk 33.776420
14+
Generate_ComposioSdk 107.296004
15+
Generate_CozeSdk 17.891651
16+
Generate_CursorAgentsSdk 13.041061
17+
Generate_DIdSdk 107.415319
18+
Generate_DeepLSdk 17.225703
19+
Generate_DeepgramMultichannelSdk 11.277537
20+
Generate_DescriptSdk 13.273608
21+
Generate_DescriptorSetInput_ScaffoldsGrpcClientProject 11.271982
22+
Generate_DescriptorSetInput_WithInvalidBinary_ShowsExplicitMessage 5.475935
23+
Generate_DoclingServeSdk 25.352062
24+
Generate_DoesNotEmitDoubleDotGeneratedFileNames 8.588101
25+
Generate_E2BSdk 13.766403
26+
Generate_ElevenLabsStreamingSdk 82.569579
27+
Generate_ExaSdk 16.504456
28+
Generate_FalSdk 17.166083
29+
Generate_FirecrawlSdk 18.456578
30+
Generate_FishAudioSdk 12.805229
31+
Generate_FlowiseSdk 15.730806
32+
Generate_ForemSdk 11.461942
33+
Generate_FreeFormObjectSchemas_UseGeneratedWrapperTypes 9.579016
34+
Generate_GladiaSdk 22.758680
35+
Generate_GoogleGeminiDiscoverySdk 15.933686
36+
Generate_GroqSdk 22.502740
37+
Generate_GroundXSdk 11.229611
38+
Generate_GuardrailsSdk 13.222305
39+
Generate_HedraSdk 17.216163
40+
Generate_HeliconeSdk 64.631989
41+
Generate_HumanloopSdk 14.545482
42+
Generate_HumeAISdk 18.937675
43+
Generate_KlingAISdk 10.700919
44+
Generate_LabelStudioSdk 59.854471
45+
Generate_LakeraSdk 11.271660
46+
Generate_LangfuseSdk 38.671667
47+
Generate_LangsmithSdk 68.860621
48+
Generate_LettaSdk 74.055314
49+
Generate_LlamaParseSdk 62.814768
50+
Generate_Mem0Sdk 27.468112
51+
Generate_MeshcapadeSdk 15.221809
52+
Generate_MeshySdk 11.192649
53+
Generate_MilvusSdk 15.081205
54+
Generate_MistralSdk 25.278406
55+
Generate_MixedbreadSdk 25.295117
56+
Generate_MurfSdk 17.140185
57+
Generate_NanonetsSdk 9.110343
58+
Generate_NightfallAISdk 11.904269
59+
Generate_NixtlaSdk 11.650309
60+
Generate_OpenApiAndProtoInput_ProducesMixedModeLayout 10.740149
61+
Generate_OpenRouterSdk 71.945790
62+
Generate_OpikSdk 44.472350
63+
Generate_PhoenixSdk 27.813597
64+
Generate_PhotoroomSdk 12.434273
65+
Generate_PortkeySdk 78.529553
66+
Generate_PredictionGuardSdk 14.019341
67+
Generate_PromptLayerSdk 32.921365
68+
Generate_ProtoInput_ScaffoldsGrpcClientProject 13.174959
69+
Generate_ProtoInput_WithMissingImport_ShowsExplicitMessage 15.428375
70+
Generate_QdrantSdk 19.669681
71+
Generate_RekaSdk 12.946108
72+
Generate_RetellAISdk 53.579624
73+
Generate_RevAISdk 12.449747
74+
Generate_RoboflowSdk 15.155737
75+
Generate_SarvamAISdk 13.408680
76+
Generate_SerperSdk 10.090231
77+
Generate_ShotstackSdk 25.184466
78+
Generate_SpeechmaticsSdk 14.641383
79+
Generate_StabilityAISdk 13.209048
80+
Generate_SynthesiaSdk 14.582139
81+
Generate_TavilySdk 15.583399
82+
Generate_TavusSdk 11.897048
83+
Generate_TogetherSdk 13.909542
84+
Generate_TripoSdk 12.562399
85+
Generate_TurbopufferSdk 13.014473
86+
Generate_TwelveLabsSdk 23.819447
87+
Generate_UpstageSdk 12.515157
88+
Generate_VapiSdk 133.425813
89+
Generate_VoyageAISdk 8.805826
90+
Generate_WeaveSdk 31.820161
91+
Generate_WeaviateSdk 20.982182
92+
Generate_WithCaseInsensitiveEnumCollisions_Builds 10.676688
93+
Generate_WithCloudSigningHelpers_SignsAwsAzureAndTencentRequests 10.721230
94+
Generate_WithCompositeAllOfRequest_DoesNotFlattenWrapperProperties 9.762113
95+
Generate_WithDeprecatedGeneratedTypeReferences_SuppressesCS0618_AndBuilds 32.806732
96+
Generate_WithDerivedAllOfInheritedPropertyCollision_Builds 7.524596
97+
Generate_WithDiscriminatorOneOfVariantNamedLikeDiscriminatorWithoutMapping_RenamesVariantMember 11.447545
98+
Generate_WithDiscriminatorOneOf_EmitsTryPickSwitchHelpers_AndBuildsNullableFlow 10.691411
99+
Generate_WithDuplicateQueryParameterNames_Builds 7.906962
100+
Generate_WithDynamicMultipartHelpers_BuildsComputedAttachmentParts 15.230473
101+
Generate_WithEnumAndPrimitiveResponses_UsesSerializerDeserialization_AndBuilds 8.102986
102+
Generate_WithEnumPathParameter_UsesWireValue_AndBuilds 26.800282
103+
Generate_WithEnvironmentFactoryOptions_BuildsAndUpdatesSnippets 11.702345
104+
Generate_WithEvaluationWorkflowHelpers_RunsDatasetScoringFailuresAndBatchPublishCases 12.222121
105+
Generate_WithFernGroupNameMatchingTagPascalCase_UsesUnsuffixedClientName_AndBuilds 10.583719
106+
Generate_WithFernIdempotencyHeaders_EmitsFirstClassSdkSupport 16.419805
107+
Generate_WithFernRequestName_UsesRequestModelName 10.190216
108+
Generate_WithFernTypeLiteralAndDateTime_UsesTypedOutput 7.727887
109+
Generate_WithHugeUnionDeprecatedConverterTypes_SuppressesCS0618_AndBuilds 12.329631
110+
Generate_WithHugeUnionTypeInfoPropertyName_Builds 9.063271
111+
Generate_WithInjectedApiKeyHeaderSecurityScheme_UsesFriendlyConstructorFileName 10.046297
112+
Generate_WithInjectedCustomHttpAuthorizationScheme_UsesCustomSchemeName 13.146797
113+
Generate_WithInlineAllOf_DoesNotEmitReflectionValidation 10.432265
114+
Generate_WithInlineOneOfEnumReferences_UsesQualifiedOneOfJsonConverter_AndBuilds 10.006503
115+
Generate_WithInlineOneOfInlineEnumAndString_UsesQualifiedOneOfJsonConverter_AndBuilds 10.478485
116+
Generate_WithInlineOneOfInlineEnumsAndParentEnum_UsesQualifiedOneOfJsonConverter_AndBuilds 16.658950
117+
Generate_WithLargeJsonSerializerContext_SplitsIntoChunkResolvers 20.107380
118+
Generate_WithNamespaceDelimiterAndExcludedDottedModel_UsesExternalType 10.388765
119+
Generate_WithNamespaceDelimiter_GeneratesNestedModelNamespaces 10.519169
120+
Generate_WithNoEndpointsOrModels_WarnsAboutEmptyGeneratedSurface 4.845481
121+
Generate_WithObjectAnyOfAndAllOf_UsesNullableTypeInfo_AndBuilds 37.171824
122+
Generate_WithObservabilityLifecycleHelpers_BatchesFlushesAndShutsDown 7.752002
123+
Generate_WithOpenApi31NumericExclusiveBounds_Builds 10.524112
124+
Generate_WithOpenApi32QueryStringCookieAndAdditionalOperations_BuildsAndEmitsSupport 11.554149
125+
Generate_WithOpenApi32SequentialMediaTypesAndItemSchema_BuildsAndEmitsStreamingSupport 10.647561
126+
Generate_WithOpenApiExamples_EmitsGeneratedSnippetManifest 6.989902
127+
Generate_WithPredictionWorkflowHelpers_RunsWaitPollFailureTimeoutAndCancelCases 10.655617
128+
Generate_WithPromptTemplateHelpers_RendersAndCachesPromptVersions 10.570712
129+
Generate_WithQuotedEnumValues_EscapesGeneratedStringLiterals_AndBuilds 43.889339
130+
Generate_WithRawModelData_BuildsTypedExtensionDataHelpers 8.773767
131+
Generate_WithRemainingOfficialSecuritySchemes_BuildsAndEmitsSupport 11.250453
132+
Generate_WithRequestAndClientOptions_EmitsOptionsSupportAndBuilds 9.545742
133+
Generate_WithRequiredDeprecatedLeafFactoryInputs_DoesNotEmitIncompleteFactory 11.425902
134+
Generate_WithRequiredNonNullableObjectProperty_FromAllOfRequest_Builds 7.763784
135+
Generate_WithRequiredNullableAnyOfRequestProperty_Builds 9.742333
136+
Generate_WithRequiredTypelessQueryParameter_UsesNonNullableStringValue_AndBuilds 16.139352
137+
Generate_WithSecuritySchemeOverride_ReplacesAuthAndSuppressesDuplicateParameters 8.033548
138+
Generate_WithServerVariableDefaults_UsesResolvedBaseUrl 16.338994
139+
Generate_WithSpeakeasyPolling_EmitsWaitHelpersAndBuilds 10.436655
140+
Generate_WithSuccessResponseHeaders_EmitsResponseWrapperMethod_AndBuilds 8.046789
141+
Generate_WithSymbolicEnumValues_UsesReadableNamesAndBuilds 10.309868
142+
Generate_WithSystemNetHttpJsonFlag_OnNetstandard20_WithPackage_Builds 10.875241
143+
Generate_WithSystemNetHttpJsonFlag_UsesHttpJsonHelpers_AndBuilds 9.685873
144+
Generate_WithValidationFlag_EmitsDataAnnotations_AndBuilds 7.755394
145+
Generate_WithWarningRegressionModels_Builds 9.934825
146+
Generate_WithWebhookVerifier_RunsReplicateStyleVerificationCases 9.774297
147+
Generate_WriterSdk 14.731776
148+
Http_ProtoInput_ShowsGrpcNotSupportedMessage 7.317541
149+
SourceGeneratorsProps_Restore_WithCentralPackageManagement 1.275784
150+
SourceGeneratorsProps_Restore_WithCentralPackageManagement_AddsOptInMicrosoftExtensionsPackages 7.146797
151+
SourceGeneratorsProps_Restore_WithoutCentralPackageManagement_AddsOptInMicrosoftExtensionsPackages 2.164988
152+
Trim_WhenValidationBuildFails_ReturnsNonZeroExitCode 8.032452
153+
Trim_WithAnalysisModeAll_DoesNotFailGeneratedTemporaryProgram 14.812644

0 commit comments

Comments
 (0)