Hello There!

We use lots of SpEL Expressions to manipulate objects and create new instances of objects (mostly strings).

In a recent poc testing performance of differents alternatives to this use case we found out immediately compiled SpelExpression starts quite fast, but after a short time (or some millons of ops) it's throughput halves.

Using Java:

java version "21" 2023-09-19
Java(TM) SE Runtime Environment Oracle GraalVM 21+35.1 (build 21+35-jvmci-23.1-b15)
Java HotSpot(TM) 64-Bit Server VM Oracle GraalVM 21+35.1 (build 21+35-jvmci-23.1-b15, mixed mode, sharing)

Spring

org.springframework.boot:spring-boot-starter-parent:3.3.4 (org.springframework:spring-expression:6.1.13)
public static void main(String... s) {
  Map<String, Object> root = Map.of("foo", "bar");
  Expression expression = new SpelExpressionParser(new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null)).parseExpression("[foo].substring(0,1)");
  AtomicInteger expressionOps = new AtomicInteger();
  int loopCount = 0;
  while (loopCount < 120) {
    loopCount++;
    expressionOps.set(0);
    try {
      CompletableFuture.supplyAsync(() -> {
        while (true) {
          expression.getValue(root);
          expressionOps.incrementAndGet();
        }
      }).get(1, TimeUnit.SECONDS);
    } catch (TimeoutException | ExecutionException | InterruptedException e) {
      // no op
    }
    System.out.println(loopCount + "\t" + expressionOps + " ops/sec");
  }
}

(I know this may not be the best way to execute a performance test)

result
1   106468809 ops/sec
2   76858700 ops/sec
3   58440833 ops/sec
4   63723883 ops/sec
5   63029349 ops/sec
6   61847942 ops/sec
7   62372654 ops/sec
8   61530194 ops/sec
9   62077340 ops/sec
10  62611521 ops/sec
11  63168501 ops/sec
12  61686067 ops/sec
13  61269262 ops/sec
14  62456026 ops/sec
15  67376736 ops/sec
16  62491016 ops/sec
17  61248502 ops/sec
18  58272199 ops/sec
19  61588422 ops/sec
20  62494339 ops/sec
21  63933284 ops/sec
22  60169862 ops/sec
23  60263788 ops/sec
24  58811820 ops/sec
25  62337873 ops/sec
26  64092402 ops/sec
27  64226228 ops/sec
28  63938694 ops/sec
29  62280862 ops/sec
30  61883910 ops/sec
31  59772020 ops/sec
32  55845966 ops/sec
33  58088455 ops/sec
34  60313176 ops/sec
35  61702904 ops/sec
36  60058085 ops/sec
37  58084405 ops/sec
38  59141274 ops/sec
39  57150345 ops/sec
40  59340823 ops/sec
41  59350710 ops/sec
42  59292279 ops/sec
43  59118264 ops/sec
44  54717859 ops/sec
45  56027505 ops/sec
46  56884413 ops/sec
47  56753541 ops/sec
48  54790069 ops/sec
49  56914703 ops/sec
50  58801666 ops/sec
51  61203990 ops/sec
52  59835119 ops/sec
53  58705683 ops/sec
54  57631493 ops/sec
55  55475370 ops/sec
56  56066452 ops/sec
57  59352852 ops/sec
58  56569769 ops/sec
59  55238780 ops/sec
60  56775104 ops/sec
61  62619808 ops/sec
62  63180309 ops/sec
63  62635372 ops/sec
64  62340839 ops/sec
65  63069140 ops/sec
66  63534674 ops/sec
67  58739326 ops/sec
68  57709071 ops/sec
69  52988673 ops/sec
70  56862452 ops/sec
71  57835487 ops/sec
72  59130428 ops/sec
73  63811116 ops/sec
74  64912248 ops/sec
75  65622490 ops/sec
76  63288306 ops/sec
77  63424435 ops/sec
78  64440044 ops/sec
79  61442975 ops/sec
80  61505119 ops/sec
81  56322460 ops/sec
82  53251012 ops/sec
83  56405411 ops/sec
84  56411554 ops/sec
85  55714282 ops/sec
86  59683349 ops/sec
87  59343053 ops/sec
88  60412439 ops/sec
89  60083218 ops/sec
90  58158879 ops/sec
91  60024897 ops/sec
92  59011061 ops/sec
93  59899315 ops/sec
94  56477961 ops/sec
95  52951155 ops/sec
96  58656927 ops/sec
97  57973760 ops/sec
98  58851680 ops/sec
99  58094087 ops/sec
100 59485404 ops/sec
101 58373482 ops/sec
102 57623260 ops/sec
103 56687019 ops/sec
104 59189517 ops/sec
105 61541489 ops/sec
106 56009505 ops/sec
107 54406023 ops/sec
108 55683797 ops/sec
109 57794844 ops/sec
110 56801866 ops/sec
111 58971439 ops/sec
112 61844510 ops/sec
113 62648223 ops/sec
114 61866733 ops/sec
115 64274086 ops/sec
116 63203469 ops/sec
117 64850860 ops/sec
118 63431436 ops/sec
119 54036480 ops/sec
120 56601604 ops/sec
---
config:
    themeVariables:
        xyChart:
            plotColorPalette: "#ff0000"
---
xychart-beta
    title "Throughput of the first 4 seconds"
    y-axis "operations" 0 --> 107000000
    x-axis "seconds" [1, 2, 3, 4, 5, 6, 7, 8]
    line [106468809, 76858700, 58440833, 63723883, 63029349, 61847942, 62372654, 61530194]

Is this not an intended use case?

Can I do something to maintain that initial throughput?

Comment From: sbrannen

Hi @Fradantim,

It appears to me that your "performance test" is suffering from the use of a ForkJoinPool, which is used by default with CompletableFuture.supplyAsync(Supplier<U>).

If you switch to a single-threaded Executor, you should see that the throughput actually increases after the initial loop and quickly stabilizes.

I reworked your example using Executors.newSingleThreadExecutor() as follows.

public static void main(String... s) {
    Map<String, Object> root = Map.of("foo", "bar");
    SpelParserConfiguration configuration = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, null);
    SpelExpressionParser parser = new SpelExpressionParser(configuration);
    Expression expression = parser.parseExpression("[foo].substring(0,1)");

    ExecutorService executor = Executors.newSingleThreadExecutor();
    AtomicInteger expressionOps = new AtomicInteger();
    for (int loopCount = 0; loopCount < 120; loopCount++) {
        expressionOps.set(0);
        try {
            CompletableFuture.supplyAsync(() -> {
                while (true) {
                    expression.getValue(root);
                    expressionOps.incrementAndGet();
                }
            }, executor).get(1, TimeUnit.SECONDS);
        }
        catch (TimeoutException | ExecutionException | InterruptedException e) {
            // no op
        }
        System.out.println(loopCount + "\t" + expressionOps + " ops/sec");
    }
}

When I run that on my machine, I see the following trend.

0   98300504 ops/sec
1   140297605 ops/sec
2   141072144 ops/sec
3   142465356 ops/sec
4   142225512 ops/sec
5   141978597 ops/sec
6   140819864 ops/sec
7   142301147 ops/sec
8   142520190 ops/sec
9   141318892 ops/sec
10  140166873 ops/sec
11  141990514 ops/sec
12  142552508 ops/sec
13  142671503 ops/sec
14  142147598 ops/sec
15  142423959 ops/sec
16  141909586 ops/sec
17  142513922 ops/sec
18  142391894 ops/sec
19  141721314 ops/sec
20  141534411 ops/sec

In light of that, I am closing this issue.

However, if you are able to create a JMH benchmark that proves otherwise, feel free to provide that benchmark here, and we will investigate.

Cheers,

Sam