Repository navigation
Commit c5ce330
[SPARK-58507][CORE] Avoid redundant closure-class parsing in ClosureCleaner's indylambda path
### What changes were proposed in this pull request?
Two changes to `ClosureCleaner`'s indylambda path:
1. Hoist the `getCapturedArgCount == 0` check above `Class.forName` and the ASM parse. It is an O(1) read of a `SerializedLambda` the method already holds, and when it is zero there is nothing to clean, so the expensive work was being done only to be discarded. Skipping the return-statement fail-fast for such closures is safe: a non-local return compiles to `throw new NonLocalReturnControl(key, value)` where `key` is allocated in the enclosing method, so a closure containing one necessarily captures that key. The null-`getCapturedArg(0)` bail-out, by contrast, deliberately stays _below_ the return-statement check: a closure with a non-local return can capture a null value as its first captured argument, so hoisting that bail-out too would silently skip the fail-fast (see the new regression test, which fails in that configuration).
2. Memoize the return-statement check per class in a `java.lang.ClassValue`. `ReturnStatementFinder` is replaced by a collecting variant (`ReturnStatementCollector`) so one parse answers for every method on the class, preserving the existing `$adapted` matching rule exactly.
Scanning granularity is unchanged: a classfile cannot be parsed per-method (ASM's `accept` walks the whole file), and both the old and new visitors decode instructions only for `apply`/`$anonfun$` methods. The previous code paid that full-class walk on every `clean()` call; the memoized version pays it once per class. The only per-parse work lost is the old throw-on-first-match early abort, which fired only when the closure was about to be rejected anyway. The stored result is a set of offending method names, or the shared empty-set singleton for any class with no non-local returns.
Why `ClassValue` rather than a map keyed on `Class`? A `ConcurrentHashMap[Class[_], _]` would strongly reference its keys and prevent classloader unloading, which matters here because Spark's REPL and `ChildFirstURLClassLoader` discard loaders routinely. `ClassValue` associates the state with the `Class` itself, so an entry becomes unreachable precisely when its class does; no unloading is blocked. Verified empirically: a populated entry does not prevent class or classloader collection, and 20000 sequentially created classes each given an entry left zero alive after GC with metaspace flat. `computeValue` may be invoked concurrently and more than once per class, which is safe because return-statement detection is a pure function of the class bytes.
As a side effect this removes a latent NPE: `getClassReader` has always been allowed to return `null` (bytecode is not resource-accessible for e.g. LambdaMetafactory-generated classes, which is why SPARK-14540 added the same guard in `getInnerClosureClasses`), and the two return-statement call sites dereferenced it unconditionally. At these sites the argument is a capturing class, which in practice has readable bytecode, so the NPE is not known to fire; the memoized check now honors the contract and skips such classes with a debug log. The fail-fast is best-effort, so skipping cannot cause incorrect execution, only a later, less friendly error if the closure really contains a non-local return.
### Why are the changes needed?
`RDD.collect()` passes `(iter: Iterator[T]) => iter.toArray` to `runJob`, which captures nothing, yet `SparkContext.runJob` cleans it unconditionally -- loading a class, reading a class file out of a JAR and running a full ASM parse, on every collect.
Profiling a Spark test JVM (`SQLQueryTestSuite`) showed:
* `ClosureCleaner` on the stack for 13.2% of CPU samples and 25.2% of all allocation samples;
* 3251 `getClassReader` invocations over 21 distinct classes -- a 155x repeat ratio, because the lambdas passed to `runJob` are declared by a handful of Spark's own classes (`WholeStageCodegenExec`, `SparkContext`, `RDD`, `Dataset`), so every job re-parses the same bytecode;
* 14.7% of indylambda cleans are non-capturing.
The cost is a per-job overhead, measured at 9.8-13.2% of test-JVM CPU across six suites including `core/RDDSuite`, which involves no SQL at all.
Note that the ClosureCleaner's indylambda path only modifies closures whose first captured argument is a Scala REPL line object or an instance of a class defined in an Ammonite session; for all other code it is purely a validator. Since modification is gated on a captured argument existing, a closure with `getCapturedArgCount == 0` can never be modified, so the hoist cannot remove cleaning, and the legacy non-indylambda path's cleaning is untouched by this PR (only its validator is memoized).
### Does this PR introduce _any_ user-facing change?
No. Behaviour is unchanged, including the fail-fast `ReturnStatementInClosureException` for every capturing closure.
### How was this patch tested?
* New regression test "return statements in closures capturing a null value are identified at cleaning time": its closure's first captured argument is a null local that precedes the `NonLocalReturnControl` key in the capture order (verified via javap: `$anonfun$run$16(String, Object, int)`), pinning the requirement that the capture-count hoist must not skip the return-statement check for capturing closures. It fails if the null-capture bail-out is hoisted above the check.
* New unit test "hasReturnStatement identifies non-local returns per method": exercises the any-method query, the exact impl-method match, the `$adapted`-suffix resolution rule (scala/scala-dev#109), a non-matching method name, and a class with no non-local returns.
* `ClosureCleanerSuite` + `ClosureCleanerSuite2`: 18/18 (`ClosureCleanerSuite`, 12/12, rerun locally against the final patch).
* `ReplSuite` + `SingletonReplSuite`: 36/36, and `AmmoniteReplE2ESuite`: 1/1. These are the two paths where cleaning actually modifies closures: the indylambda path only rewrites Scala REPL and Ammonite closures, so every other suite exercises the branch where a regression would be invisible.
* `DataFrameSuite`: 176/176.
* Effect confirmed by profiling the patched build on the same suite: `xbean.asm9` falls from 11.02% of samples to 0.17%, `getClassReader` to zero.
* The above tests / profiling was on a Linux VPS box. Independently reproduced on a second machine (macOS, JDK 21, JFR `profile` settings), comparing baseline and patched `ClosureCleaner` swapped in front of an otherwise identical classpath:
- `core/RDDSuite` end-to-end (79/79 green in both): cleaning-related frames fell from 8.1% of execution samples to 0.0%, `getClassReader` calls from 1306 over 11 distinct classes (a 119x repeat ratio) to 13, and suite wall clock dropped ~8.5%.
- A loop of minimal `collect()`/`count()` jobs showed where the time goes: the driver re-parses `SparkContext.class` (204 KB) and `RDD.class` (189 KB) about five times per job; per-iteration wall time halved (11.1 ms -> 5.5 ms).
- Microbenchmark of `SparkClosureCleaner.clean()` alone (baseline cost scales with the capturing class's file size; the memoized path is size-independent): for a 9 KB capturing class, 46.4 us -> 2.0 us per call; for a 113 KB one, 340 us -> 4.8 us. Non-capturing: 44.9 us -> 0.28 us. The memo's worst case (a never-repeated closure class) pays the same single parse as before plus ~360 bytes; measured repeat ratios on real suites were 119x (`RDDSuite`) and 155x (`DataFrameSuite`).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5; refined with Claude Fable 5
Closes #57710 from JoshRosen/SPARK-58507-closurecleaner-redundant-parsing.
Authored-by: Josh Rosen <rosenville@gmail.com>
Signed-off-by: yangjie01 <yangjie01@baidu.com>1 parent 62c57b4 commit c5ce330
2 files changed
Lines changed: 119 additions & 22 deletions
File tree
- common/utils/src/main/scala/org/apache/spark/util
- core/src/test/scala/org/apache/spark/util
Lines changed: 56 additions & 22 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
24 | 25 | | |
25 | 26 | | |
26 | 27 | | |
| |||
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
37 | 72 | | |
38 | 73 | | |
39 | 74 | | |
| |||
226 | 261 | | |
227 | 262 | | |
228 | 263 | | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
229 | 271 | | |
230 | 272 | | |
231 | 273 | | |
| |||
234 | 276 | | |
235 | 277 | | |
236 | 278 | | |
237 | | - | |
238 | | - | |
239 | | - | |
240 | | - | |
241 | | - | |
242 | | - | |
243 | | - | |
244 | | - | |
| 279 | + | |
| 280 | + | |
245 | 281 | | |
246 | 282 | | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
247 | 286 | | |
248 | 287 | | |
249 | 288 | | |
| |||
312 | 351 | | |
313 | 352 | | |
314 | 353 | | |
315 | | - | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
316 | 357 | | |
317 | 358 | | |
318 | 359 | | |
| |||
1075 | 1116 | | |
1076 | 1117 | | |
1077 | 1118 | | |
1078 | | - | |
1079 | | - | |
| 1119 | + | |
| 1120 | + | |
| 1121 | + | |
| 1122 | + | |
1080 | 1123 | | |
1081 | 1124 | | |
1082 | 1125 | | |
1083 | 1126 | | |
1084 | 1127 | | |
1085 | | - | |
1086 | | - | |
1087 | | - | |
1088 | | - | |
1089 | | - | |
1090 | | - | |
1091 | | - | |
1092 | | - | |
1093 | 1128 | | |
1094 | 1129 | | |
1095 | | - | |
1096 | | - | |
1097 | | - | |
| 1130 | + | |
| 1131 | + | |
1098 | 1132 | | |
1099 | 1133 | | |
1100 | 1134 | | |
| |||
Lines changed: 63 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
60 | 60 | | |
61 | 61 | | |
62 | 62 | | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
63 | 69 | | |
64 | 70 | | |
65 | 71 | | |
66 | 72 | | |
67 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
68 | 97 | | |
69 | 98 | | |
70 | 99 | | |
| |||
199 | 228 | | |
200 | 229 | | |
201 | 230 | | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
202 | 265 | | |
203 | 266 | | |
204 | 267 | | |
| |||
0 commit comments