Skip to content

Commit 023ca6d

Browse files
lofifnccodex
andauthored
[Java] Cache DoFn invoker constructors per instance (#39310)
* Cache DoFn invoker constructors per instance Co-Authored-By: Codex <noreply@openai.com> * Add DoFn invoker cache fix to changelog Co-Authored-By: Codex <noreply@openai.com> * Clarify DoFn cache performance improvement Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 39c60f0 commit 023ca6d

3 files changed

Lines changed: 103 additions & 28 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
## Bugfixes
8484

8585
* Fixed unbounded checkpoint state growth for splittable DoFns that self-checkpoint on the portable Flink runner (Java) ([#27648](https://github.com/apache/beam/issues/27648)).
86+
* Improved Java pipeline performance by avoiding repeated `DoFn` type descriptor resolution when creating cached invokers ([#39309](https://github.com/apache/beam/issues/39309)).
8687
* Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
8788

8889
## Security Fixes

sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/reflect/ByteBuddyDoFnInvokerFactory.java

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.util.Map;
3030
import java.util.Objects;
3131
import java.util.concurrent.ConcurrentHashMap;
32+
import java.util.concurrent.ConcurrentMap;
3233
import net.bytebuddy.ByteBuddy;
3334
import net.bytebuddy.description.field.FieldDescription;
3435
import net.bytebuddy.description.method.MethodDescription;
@@ -113,6 +114,7 @@
113114
import org.apache.beam.sdk.values.TypeDescriptor;
114115
import org.apache.beam.sdk.values.TypeDescriptors;
115116
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
117+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.MapMaker;
116118
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Maps;
117119
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.primitives.Primitives;
118120
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@@ -242,6 +244,22 @@ public String toString() {
242244
private final Map<InvokerCacheKey, Constructor<?>> byteBuddyInvokerConstructorCache =
243245
new ConcurrentHashMap<>();
244246

247+
/**
248+
* A weak, identity-keyed cache of the constructor selected for each {@link DoFn} instance.
249+
*
250+
* <p>Selecting a constructor resolves the {@link DoFn}'s input and output type descriptors. Some
251+
* runners create a new invoker for the same {@link DoFn} at every bundle boundary, so doing that
252+
* work on every invocation can be expensive. A deserialized {@link DoFn} is a new instance, so
253+
* its constructor is selected again; later invoker creation for that instance reuses the cached
254+
* selection.
255+
*
256+
* <p>The constructor values do not reference their {@link DoFn} keys, and weak keys allow unused
257+
* instances to be collected. {@link MapMaker#weakKeys} also uses identity equality, which is
258+
* required because separate instances of the same class may have different generic types.
259+
*/
260+
private final ConcurrentMap<DoFn<?, ?>, Constructor<?>> byteBuddyInvokerConstructorInstanceCache =
261+
new MapMaker().weakKeys().makeMap();
262+
245263
private ByteBuddyDoFnInvokerFactory() {}
246264

247265
/** Returns the {@link DoFnInvoker} for the given {@link DoFn}. */
@@ -330,39 +348,15 @@ public <InputT, OutputT> DoFnInvoker<InputT, OutputT> newByteBuddyInvoker(
330348
signature.fnClass(),
331349
fn.getClass());
332350

333-
// Extract input and output type descriptors to distinguish generic instantiations.
334-
// Fall back to Object.class if unavailable. When type info is lost, different generic
335-
// instantiations share an invoker, which is acceptable since the DoFn class in the cache
336-
// key prevents collisions between different DoFn classes.
337-
TypeDescriptor<InputT> inputType;
338-
try {
339-
inputType = fn.getInputTypeDescriptor();
340-
} catch (Exception e) {
341-
// Some DoFns (like MapElements) throw IllegalStateException if queried after
342-
// serialization.
343-
// In this case, we fall back to the raw class behavior (Object).
344-
inputType = null;
345-
}
346-
if (inputType == null) {
347-
inputType = (TypeDescriptor<InputT>) TypeDescriptor.of(Object.class);
348-
}
349-
350-
TypeDescriptor<OutputT> outputType;
351-
try {
352-
outputType = fn.getOutputTypeDescriptor();
353-
} catch (Exception e) {
354-
// Same as above: fall back to Object if type info is unavailable.
355-
outputType = null;
356-
}
357-
if (outputType == null) {
358-
outputType = (TypeDescriptor<OutputT>) TypeDescriptor.of(Object.class);
359-
}
351+
Constructor<?> invokerConstructor =
352+
byteBuddyInvokerConstructorInstanceCache.computeIfAbsent(
353+
fn, unused -> getByteBuddyInvokerConstructor(signature, fn));
360354

361355
try {
362356
@SuppressWarnings("unchecked")
363357
DoFnInvokerBase<InputT, OutputT, DoFn<InputT, OutputT>> invoker =
364358
(DoFnInvokerBase<InputT, OutputT, DoFn<InputT, OutputT>>)
365-
getByteBuddyInvokerConstructor(signature, inputType, outputType).newInstance(fn);
359+
invokerConstructor.newInstance(fn);
366360

367361
if (signature.onTimerMethods() != null) {
368362
for (OnTimerMethod onTimerMethod : signature.onTimerMethods().values()) {
@@ -389,6 +383,39 @@ public <InputT, OutputT> DoFnInvoker<InputT, OutputT> newByteBuddyInvoker(
389383
}
390384
}
391385

386+
private <InputT, OutputT> Constructor<?> getByteBuddyInvokerConstructor(
387+
DoFnSignature signature, DoFn<InputT, OutputT> fn) {
388+
// Extract input and output type descriptors to distinguish generic instantiations.
389+
// Fall back to Object.class if unavailable. When type info is lost, different generic
390+
// instantiations share an invoker, which is acceptable since the DoFn class in the cache
391+
// key prevents collisions between different DoFn classes.
392+
TypeDescriptor<InputT> inputType;
393+
try {
394+
inputType = fn.getInputTypeDescriptor();
395+
} catch (Exception e) {
396+
// Some DoFns (like MapElements) throw IllegalStateException if queried after
397+
// serialization.
398+
// In this case, we fall back to the raw class behavior (Object).
399+
inputType = null;
400+
}
401+
if (inputType == null) {
402+
inputType = (TypeDescriptor<InputT>) TypeDescriptor.of(Object.class);
403+
}
404+
405+
TypeDescriptor<OutputT> outputType;
406+
try {
407+
outputType = fn.getOutputTypeDescriptor();
408+
} catch (Exception e) {
409+
// Same as above: fall back to Object if type info is unavailable.
410+
outputType = null;
411+
}
412+
if (outputType == null) {
413+
outputType = (TypeDescriptor<OutputT>) TypeDescriptor.of(Object.class);
414+
}
415+
416+
return getByteBuddyInvokerConstructor(signature, inputType, outputType);
417+
}
418+
392419
/**
393420
* Returns a generated constructor for a {@link DoFnInvoker} for the given {@link DoFnSignature}
394421
* and specific generic types.

sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/reflect/DoFnInvokersTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
import org.apache.beam.sdk.values.TypeDescriptor;
8383
import org.apache.beam.sdk.values.TypeDescriptors;
8484
import org.apache.beam.sdk.values.WindowedValues;
85+
import org.checkerframework.checker.nullness.qual.Nullable;
8586
import org.joda.time.Instant;
8687
import org.junit.Before;
8788
import org.junit.Rule;
@@ -1440,6 +1441,18 @@ public TypeDescriptor<T> getInputTypeDescriptor() {
14401441
public TypeDescriptor<T> getOutputTypeDescriptor() {
14411442
return typeDescriptor;
14421443
}
1444+
1445+
// Deliberately make separate instances equal. The per-instance cache must use identity
1446+
// because equal instances can have different generic types.
1447+
@Override
1448+
public boolean equals(@Nullable Object other) {
1449+
return other instanceof DynamicTypeDoFn;
1450+
}
1451+
1452+
@Override
1453+
public int hashCode() {
1454+
return 0;
1455+
}
14431456
}
14441457

14451458
DoFn<String, String> stringFn = new DynamicTypeDoFn<>(TypeDescriptors.strings());
@@ -1456,4 +1469,38 @@ public TypeDescriptor<T> getOutputTypeDescriptor() {
14561469
stringInvoker.getClass(),
14571470
intInvoker.getClass());
14581471
}
1472+
1473+
@Test
1474+
public void testTypeDescriptorResolutionIsCachedPerDoFnInstance() {
1475+
class CountingTypeDoFn extends DoFn<String, String> {
1476+
private int inputTypeDescriptorCalls;
1477+
private int outputTypeDescriptorCalls;
1478+
1479+
@ProcessElement
1480+
public void processElement(@Element String element, OutputReceiver<String> out) {
1481+
out.output(element);
1482+
}
1483+
1484+
@Override
1485+
public TypeDescriptor<String> getInputTypeDescriptor() {
1486+
inputTypeDescriptorCalls++;
1487+
return TypeDescriptors.strings();
1488+
}
1489+
1490+
@Override
1491+
public TypeDescriptor<String> getOutputTypeDescriptor() {
1492+
outputTypeDescriptorCalls++;
1493+
return TypeDescriptors.strings();
1494+
}
1495+
}
1496+
1497+
CountingTypeDoFn fn = new CountingTypeDoFn();
1498+
1499+
DoFnInvoker<String, String> firstInvoker = DoFnInvokers.invokerFor(fn);
1500+
DoFnInvoker<String, String> secondInvoker = DoFnInvokers.invokerFor(fn);
1501+
1502+
assertSame(firstInvoker.getClass(), secondInvoker.getClass());
1503+
assertEquals(1, fn.inputTypeDescriptorCalls);
1504+
assertEquals(1, fn.outputTypeDescriptorCalls);
1505+
}
14591506
}

0 commit comments

Comments
 (0)