Skip to content

Commit 27d699e

Browse files
[Gemini] Add client-side throttling to Java Remote Inference (#39194)
* [Gemini] Add client-side throttling to Java Remote Inference * Expose other configuration fields * Apply suggestions from code review Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * argument validation * unit test fix * Docstrings * disable on 0 throttlingDelaySecs --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent bbb1c75 commit 27d699e

3 files changed

Lines changed: 194 additions & 9 deletions

File tree

sdks/java/ml/inference/remote/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ ext.summary = "Base framework for remote ml inference"
2929
dependencies {
3030
// Core Beam SDK
3131
implementation project(path: ":sdks:java:core", configuration: "shadow")
32+
implementation project(":sdks:java:io:components")
3233

3334
compileOnly "com.google.auto.value:auto-value-annotations:1.11.0"
3435
annotationProcessor "com.google.auto.value:auto-value:1.11.0"

sdks/java/ml/inference/remote/src/main/java/org/apache/beam/sdk/ml/inference/remote/RemoteInference.java

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
import com.google.auto.value.AutoValue;
2323
import java.util.List;
24+
import org.apache.beam.sdk.io.components.throttling.ReactiveThrottler;
2425
import org.apache.beam.sdk.transforms.BatchElements;
2526
import org.apache.beam.sdk.transforms.DoFn;
2627
import org.apache.beam.sdk.transforms.PTransform;
@@ -48,15 +49,12 @@
4849
* // Apply remote inference transform
4950
* PCollection<OpenAIModelInput> inputs = pipeline.apply(Create.of(
5051
* OpenAIModelInput.create("An excellent B2B SaaS solution that streamlines business processes efficiently."),
51-
* OpenAIModelInput.create("Really impressed with the innovative features!")
52-
* ));
52+
* OpenAIModelInput.create("Really impressed with the innovative features!")));
5353
*
54-
* PCollection<Iterable<PredictionResult<OpenAIModelInput, OpenAIModelResponse>>> results =
55-
* inputs.apply(
56-
* RemoteInference.<OpenAIModelInput, OpenAIModelResponse>invoke()
57-
* .handler(OpenAIModelHandler.class)
58-
* .withParameters(params)
59-
* );
54+
* PCollection<Iterable<PredictionResult<OpenAIModelInput, OpenAIModelResponse>>> results = inputs.apply(
55+
* RemoteInference.<OpenAIModelInput, OpenAIModelResponse>invoke()
56+
* .handler(OpenAIModelHandler.class)
57+
* .withParameters(params));
6058
* }</pre>
6159
*/
6260
@SuppressWarnings({"rawtypes", "unchecked"})
@@ -82,6 +80,14 @@ public abstract static class Invoke<InputT extends BaseInput, OutputT extends Ba
8280

8381
abstract BatchElements.@Nullable BatchConfig batchConfig();
8482

83+
abstract @Nullable Integer throttleDelaySecs();
84+
85+
abstract @Nullable Long samplePeriodMs();
86+
87+
abstract @Nullable Long sampleUpdateMs();
88+
89+
abstract @Nullable Double overloadRatio();
90+
8591
abstract Builder<InputT, OutputT> builder();
8692

8793
@AutoValue.Builder
@@ -94,6 +100,14 @@ abstract Builder<InputT, OutputT> setHandler(
94100

95101
abstract Builder<InputT, OutputT> setBatchConfig(BatchElements.BatchConfig batchConfig);
96102

103+
abstract Builder<InputT, OutputT> setThrottleDelaySecs(Integer throttleDelaySecs);
104+
105+
abstract Builder<InputT, OutputT> setSamplePeriodMs(Long samplePeriodMs);
106+
107+
abstract Builder<InputT, OutputT> setSampleUpdateMs(Long sampleUpdateMs);
108+
109+
abstract Builder<InputT, OutputT> setOverloadRatio(Double overloadRatio);
110+
97111
abstract Invoke<InputT, OutputT> build();
98112
}
99113

@@ -113,6 +127,42 @@ public Invoke<InputT, OutputT> withBatchConfig(BatchElements.BatchConfig batchCo
113127
return builder().setBatchConfig(batchConfig).build();
114128
}
115129

130+
/**
131+
* Configures the throttling delay when the client is preemptively throttled. Defaults to 5
132+
* seconds. A value of 0 disables throttling. For more context, see {@link ReactiveThrottler}
133+
*/
134+
public Invoke<InputT, OutputT> withThrottleDelaySecs(int throttleDelaySecs) {
135+
checkArgument(throttleDelaySecs >= 0, "throttleDelaySecs must be non-negative");
136+
return builder().setThrottleDelaySecs(throttleDelaySecs).build();
137+
}
138+
139+
/**
140+
* Configures the length of history to consider when setting throttling probability. Defaults to
141+
* a sample period of 1000ms. For more context, see {@link AdaptiveThrottler}
142+
*/
143+
public Invoke<InputT, OutputT> withSamplePeriodMs(long samplePeriodMs) {
144+
checkArgument(samplePeriodMs > 0, "samplePeriodMs must be positive");
145+
return builder().setSamplePeriodMs(samplePeriodMs).build();
146+
}
147+
148+
/**
149+
* Configures the granularity of time buckets that we store data in for throttling. Defaults to
150+
* a sample period of 1000ms. For more context, see {@link AdaptiveThrottler}
151+
*/
152+
public Invoke<InputT, OutputT> withSampleUpdateMs(long sampleUpdateMs) {
153+
checkArgument(sampleUpdateMs > 0, "sampleUpdateMs must be positive");
154+
return builder().setSampleUpdateMs(sampleUpdateMs).build();
155+
}
156+
157+
/**
158+
* Configures the target ratio between requests sent and successful requests. Defaults to an
159+
* overload ratio of 2.0. For more context, see {@link AdaptiveThrottler}
160+
*/
161+
public Invoke<InputT, OutputT> withOverloadRatio(double overloadRatio) {
162+
checkArgument(overloadRatio > 0, "overloadRatio must be positive");
163+
return builder().setOverloadRatio(overloadRatio).build();
164+
}
165+
116166
@Override
117167
public PCollection<Iterable<PredictionResult<InputT, OutputT>>> expand(
118168
PCollection<InputT> input) {
@@ -151,10 +201,19 @@ static class RemoteInferenceFn<InputT extends BaseInput, OutputT extends BaseRes
151201
private final BaseModelParameters parameters;
152202
private transient @Nullable BaseModelHandler modelHandler;
153203
private final RetryHandler retryHandler;
204+
private final int throttleDelaySecs;
205+
private final long samplePeriodMs;
206+
private final long sampleUpdateMs;
207+
private final double overloadRatio;
208+
private transient @Nullable ReactiveThrottler throttler;
154209

155210
RemoteInferenceFn(Invoke<InputT, OutputT> spec) {
156211
this.handlerClass = spec.handler();
157212
this.parameters = spec.parameters();
213+
this.throttleDelaySecs = spec.throttleDelaySecs() != null ? spec.throttleDelaySecs() : 5;
214+
this.samplePeriodMs = spec.samplePeriodMs() != null ? spec.samplePeriodMs() : 1000L;
215+
this.sampleUpdateMs = spec.sampleUpdateMs() != null ? spec.sampleUpdateMs() : 1000L;
216+
this.overloadRatio = spec.overloadRatio() != null ? spec.overloadRatio() : 2.0;
158217
retryHandler = RetryHandler.withDefaults();
159218
}
160219

@@ -164,15 +223,40 @@ public void setupHandler() {
164223
try {
165224
this.modelHandler = handlerClass.getDeclaredConstructor().newInstance();
166225
this.modelHandler.createClient(parameters);
226+
if (throttleDelaySecs > 0) {
227+
this.throttler =
228+
new ReactiveThrottler(
229+
samplePeriodMs,
230+
sampleUpdateMs,
231+
overloadRatio,
232+
"RemoteInference",
233+
throttleDelaySecs);
234+
}
167235
} catch (Exception e) {
168236
throw new RuntimeException("Failed to instantiate handler: " + handlerClass.getName(), e);
169237
}
170238
}
239+
171240
/** Perform Inference. */
172241
@ProcessElement
173242
public void processElement(ProcessContext c) throws Exception {
174243
Iterable<PredictionResult<InputT, OutputT>> response =
175-
retryHandler.execute(() -> modelHandler.request(c.element()));
244+
retryHandler.execute(
245+
() -> {
246+
if (throttler != null) {
247+
throttler.throttle();
248+
}
249+
long reqTime = System.currentTimeMillis();
250+
if (modelHandler == null) {
251+
throw new IllegalStateException("modelHandler is not initialized");
252+
}
253+
Iterable<PredictionResult<InputT, OutputT>> result =
254+
modelHandler.request(c.element());
255+
if (throttler != null) {
256+
throttler.successfulRequest(reqTime);
257+
}
258+
return result;
259+
});
176260
c.output(response);
177261
}
178262
}

sdks/java/ml/inference/remote/src/test/java/org/apache/beam/sdk/ml/inference/remote/RemoteInferenceTest.java

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import static org.junit.Assert.assertTrue;
2424
import static org.junit.Assert.fail;
2525

26+
import java.util.ArrayList;
2627
import java.util.Arrays;
2728
import java.util.Collections;
2829
import java.util.List;
@@ -256,6 +257,30 @@ public Iterable<PredictionResult<TestInput, TestOutput>> request(List<TestInput>
256257
}
257258
}
258259

260+
// Mock handler that fails repeatedly but eventually succeeds to trigger throttling
261+
public static class MockThrottlingHandler
262+
implements BaseModelHandler<TestParameters, TestInput, TestOutput> {
263+
264+
private int requestCount = 0;
265+
266+
@Override
267+
public void createClient(TestParameters parameters) {}
268+
269+
@Override
270+
public Iterable<PredictionResult<TestInput, TestOutput>> request(List<TestInput> input) {
271+
requestCount++;
272+
// Fail 2 out of 3 requests. RetryHandler defaults to 3 max retries,
273+
// so the 3rd attempt will succeed, avoiding pipeline failure while
274+
// accumulating enough failures to trigger client-side throttling.
275+
if (requestCount % 3 != 0) {
276+
throw new RuntimeException("Intentional failure to trigger throttling");
277+
}
278+
return input.stream()
279+
.map(i -> PredictionResult.create(i, new TestOutput("processed-" + i.getModelInput())))
280+
.collect(Collectors.toList());
281+
}
282+
}
283+
259284
private static boolean containsMessage(Throwable e, String message) {
260285
Throwable current = e;
261286
while (current != null) {
@@ -617,4 +642,79 @@ public void testWithEmptyHandler() {
617642
"Expected message to contain 'handler() is required', but got: " + thrown.getMessage(),
618643
thrown.getMessage().contains("handler() is required"));
619644
}
645+
646+
@Test
647+
public void testThrottlingBehavior() {
648+
TestParameters params = TestParameters.builder().setConfig("test-config").build();
649+
650+
// Create enough inputs to ensure throttling probabilistically triggers
651+
List<TestInput> inputs = new ArrayList<>();
652+
for (int i = 0; i < 30; i++) {
653+
inputs.add(new TestInput("input" + i));
654+
}
655+
656+
PCollection<TestInput> inputCollection =
657+
pipeline.apply(
658+
"CreateInputs", Create.of(inputs).withCoder(SerializableCoder.of(TestInput.class)));
659+
660+
// Configure BatchElements to force a batch of exactly 1 so we get enough requests
661+
org.apache.beam.sdk.transforms.BatchElements.BatchConfig batchConfig =
662+
org.apache.beam.sdk.transforms.BatchElements.BatchConfig.builder()
663+
.withMinBatchSize(1)
664+
.withMaxBatchSize(1)
665+
.build();
666+
667+
PCollection<Iterable<PredictionResult<TestInput, TestOutput>>> results =
668+
inputCollection.apply(
669+
"RemoteInference",
670+
RemoteInference.<TestInput, TestOutput>invoke()
671+
.handler(MockThrottlingHandler.class)
672+
.withBatchConfig(batchConfig)
673+
// Use large sample periods so the 1s retry delay doesn't flush the history
674+
.withSamplePeriodMs(60000L)
675+
.withSampleUpdateMs(60000L)
676+
// Set to 1 second to minimize test wait time while still verifying throttling
677+
.withThrottleDelaySecs(1)
678+
.withOverloadRatio(1.1)
679+
.withParameters(params));
680+
681+
PAssert.that(results)
682+
.satisfies(
683+
batches -> {
684+
int totalElements = 0;
685+
for (Iterable<PredictionResult<TestInput, TestOutput>> batch : batches) {
686+
totalElements += (int) StreamSupport.stream(batch.spliterator(), false).count();
687+
}
688+
assertEquals("Expected all 30 elements to succeed", 30, totalElements);
689+
return null;
690+
});
691+
692+
org.apache.beam.sdk.PipelineResult result = pipeline.run();
693+
result.waitUntilFinish();
694+
695+
// Verify that the throttling metrics were populated.
696+
// The metric name is defined by Metrics.THROTTLE_TIME_COUNTER_NAME which evaluates to
697+
// "cumulativeThrottlingSeconds".
698+
org.apache.beam.sdk.metrics.MetricQueryResults metrics =
699+
result
700+
.metrics()
701+
.queryMetrics(
702+
org.apache.beam.sdk.metrics.MetricsFilter.builder()
703+
.addNameFilter(
704+
org.apache.beam.sdk.metrics.MetricNameFilter.named(
705+
"RemoteInference",
706+
org.apache.beam.sdk.metrics.Metrics.THROTTLE_TIME_COUNTER_NAME))
707+
.build());
708+
709+
// Throttling may not trigger if random numbers are very skewed, but with 30 elements * 2
710+
// failures each = 60 failures,
711+
// and overloadRatio=1.1, the chance of not throttling at least once is very small.
712+
// If this test becomes flaky, increase the number of inputs.
713+
boolean hasThrottled =
714+
StreamSupport.stream(metrics.getCounters().spliterator(), false)
715+
.anyMatch(
716+
metricResult -> metricResult.getAttempted() > 0 || metricResult.getCommitted() > 0);
717+
718+
assertTrue("Expected client-side throttling to trigger at least once", hasThrottled);
719+
}
620720
}

0 commit comments

Comments
 (0)