Skip to content

Commit c991e54

Browse files
author
Keith Hudnall
committed
Add zip-fs vs exploded-directory ELM load benchmark for #69
1 parent 7759479 commit c991e54

1 file changed

Lines changed: 241 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package dev.getelements.elements.sdk.test;
2+
3+
import dev.getelements.elements.sdk.Element;
4+
import dev.getelements.elements.sdk.ElementPathLoader;
5+
import dev.getelements.elements.sdk.MutableElementRegistry;
6+
import dev.getelements.elements.sdk.PermittedTypesClassLoader;
7+
import dev.getelements.elements.sdk.util.TemporaryFiles;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
import org.testng.annotations.AfterClass;
11+
import org.testng.annotations.BeforeClass;
12+
import org.testng.annotations.Test;
13+
14+
import java.io.IOException;
15+
import java.net.URL;
16+
import java.net.URLClassLoader;
17+
import java.nio.file.FileSystem;
18+
import java.nio.file.FileSystems;
19+
import java.nio.file.Files;
20+
import java.nio.file.Path;
21+
import java.util.Arrays;
22+
import java.util.List;
23+
import java.util.zip.ZipEntry;
24+
import java.util.zip.ZipOutputStream;
25+
26+
import static dev.getelements.elements.sdk.ElementPathLoader.CLASSPATH_DIR;
27+
import static dev.getelements.elements.sdk.test.TestElementArtifact.VARIANT_A;
28+
import static dev.getelements.elements.sdk.test.TestElementSpi.GUICE_7_0_X;
29+
import static dev.getelements.elements.sdk.test.TestUtils.layoutSkeletonElement;
30+
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
31+
import static org.testng.Assert.assertEquals;
32+
33+
/**
34+
* Throwaway benchmark for issue #69: compares cold-load time for a representative element loaded two ways:
35+
*
36+
* <ul>
37+
* <li><b>zip-fs</b> — today's behavior: the {@code .elm} is mounted as an NIO zip {@link FileSystem} and
38+
* classes/resources are read through it.</li>
39+
* <li><b>exploded</b> — the proposed alternative: the {@code .elm} is extracted to a plain directory once
40+
* up front, and loaded from there via ordinary {@code file://} URLs.</li>
41+
* </ul>
42+
*
43+
* <p>The element under test is a purpose-built synthetic fixture (skeleton layout + {@link TestElementArtifact
44+
* #VARIANT_A}'s own compiled classes only, via {@link TestArtifactRegistry#unpackArtifact}), deliberately
45+
* <b>without</b> the {@code lib/} jars a real packaged {@code .elm} bundles. Using a real {@code .elm}
46+
* (e.g. from {@link TestArtifactRegistry#findElmPath}) here fails in exploded mode: every
47+
* {@code sdk-test-element-*} archive bundles the base {@code sdk-test-element} module's own compiled jar
48+
* under {@code lib/}, which carries its own {@code @ElementDefinition}. In zip-fs mode ClassGraph can't scan
49+
* that nested jar at all (it's exposed via the custom {@code elm://} URL scheme), so the duplicate definition
50+
* is silently invisible; once exploded to a real directory ClassGraph <i>can</i> scan it and correctly rejects
51+
* the load as ambiguous. That's a genuine, separate finding for #69 (exploding to disk isn't just faster, it
52+
* makes ClassGraph see things it currently can't — including latent duplicate-definition bugs), but it means
53+
* a real `.elm` isn't an apples-to-apples timing fixture without first fixing that ambiguity. This fixture
54+
* sidesteps it so the numbers below measure loading cost alone.
55+
*
56+
* <p>This is not a hard pass/fail gate — timings are inherently noisy on a shared/dev machine and there is no
57+
* JMH harness in this repo to control for that (see #69). It exists purely to produce real numbers to decide
58+
* whether the exploded-directory approach is worth pursuing as a supported loading mode. Results are logged,
59+
* not asserted on, aside from a sanity check that both modes load the same element correctly.
60+
*/
61+
public class ElmExplodeVsZipFsBenchmarkTest {
62+
63+
private static final Logger logger = LoggerFactory.getLogger(ElmExplodeVsZipFsBenchmarkTest.class);
64+
65+
private static final int WARMUP_ITERATIONS = 3;
66+
67+
private static final int MEASURED_ITERATIONS = 10;
68+
69+
private static final TestArtifactRegistry testArtifactRegistry = new TestArtifactRegistry();
70+
71+
private static final TemporaryFiles temporaryFiles = new TemporaryFiles(ElmExplodeVsZipFsBenchmarkTest.class);
72+
73+
private Path elmPath;
74+
75+
private Path explodedDirectory;
76+
77+
@BeforeClass
78+
public void setUp() throws IOException {
79+
80+
// Build a clean synthetic element: skeleton layout + VARIANT_A's own compiled classes only
81+
// (no bundled lib/ dependency jars, so there's no nested-module-definition ambiguity for
82+
// ClassGraph to trip on in exploded mode). The loader expects <root>/<element-dir>/{spi,lib,
83+
// classpath}, so the skeleton lives one level below the root we hand to the loader.
84+
final var sourceDirectory = temporaryFiles.createTempDirectory("source");
85+
final var elementDirectory = sourceDirectory.resolve("synthetic-element");
86+
layoutSkeletonElement(elementDirectory, VARIANT_A.getAttributes());
87+
testArtifactRegistry.unpackArtifact(VARIANT_A, elementDirectory.resolve(CLASSPATH_DIR));
88+
89+
elmPath = temporaryFiles.createTempFile("synthetic", ".elm");
90+
zip(sourceDirectory, elmPath);
91+
92+
explodedDirectory = temporaryFiles.createTempDirectory("exploded");
93+
94+
final var explodeStart = System.nanoTime();
95+
explode(elmPath, explodedDirectory);
96+
final var explodeMillis = (System.nanoTime() - explodeStart) / 1_000_000.0;
97+
98+
logger.info("Synthetic ELM under test: {} ({} bytes). One-time explode to {} took {} ms.",
99+
elmPath, Files.size(elmPath), explodedDirectory, explodeMillis);
100+
}
101+
102+
@AfterClass
103+
public void tearDown() {
104+
TemporaryFiles.deleteRecursively(explodedDirectory);
105+
}
106+
107+
/** Zips every entry of a directory tree into a single archive, preserving relative paths. */
108+
private static void zip(final Path sourceDirectory, final Path zipFile) throws IOException {
109+
try (final var zos = new ZipOutputStream(Files.newOutputStream(zipFile))) {
110+
try (var stream = Files.walk(sourceDirectory)) {
111+
for (final var source : (Iterable<Path>) stream.filter(p -> !Files.isDirectory(p))::iterator) {
112+
final var relative = sourceDirectory.relativize(source).toString().replace('\\', '/');
113+
zos.putNextEntry(new ZipEntry(relative));
114+
Files.copy(source, zos);
115+
zos.closeEntry();
116+
}
117+
}
118+
}
119+
}
120+
121+
/** Copies every entry of the ELM zip archive into a real directory tree, preserving relative paths. */
122+
private static void explode(final Path elm, final Path destination) throws IOException {
123+
try (final var fs = FileSystems.newFileSystem(elm)) {
124+
final var root = fs.getPath("/");
125+
try (var stream = Files.walk(root)) {
126+
for (final var source : (Iterable<Path>) stream::iterator) {
127+
final var relative = root.relativize(source).toString();
128+
if (relative.isEmpty()) {
129+
continue;
130+
}
131+
final var target = destination.resolve(relative);
132+
if (Files.isDirectory(source)) {
133+
Files.createDirectories(target);
134+
} else {
135+
Files.createDirectories(target.getParent());
136+
Files.copy(source, target, REPLACE_EXISTING);
137+
}
138+
}
139+
}
140+
}
141+
}
142+
143+
private URLClassLoader newSpiClassLoader() {
144+
final var spiUrls = testArtifactRegistry.findSpiUrls(GUICE_7_0_X).toArray(URL[]::new);
145+
return new URLClassLoader("elm-benchmark-spi", spiUrls, Thread.currentThread().getContextClassLoader());
146+
}
147+
148+
private List<Element> loadFromZipFs() throws IOException {
149+
try (final var fs = FileSystems.newFileSystem(elmPath);
150+
final var spiCl = newSpiClassLoader()) {
151+
152+
final var elementRegistry = MutableElementRegistry.newDefaultInstance();
153+
final var loader = ElementPathLoader.newDefaultInstance();
154+
final var parent = new PermittedTypesClassLoader();
155+
156+
try {
157+
return loader.load(ElementPathLoader.LoadConfiguration.builder()
158+
.registry(elementRegistry)
159+
.paths(List.of(fs.getPath("/")))
160+
.parent(parent)
161+
.spiProvider((parentCl, path) -> spiCl)
162+
.build()
163+
).toList();
164+
} finally {
165+
elementRegistry.close();
166+
}
167+
}
168+
}
169+
170+
private List<Element> loadFromExplodedDirectory() throws IOException {
171+
172+
try (final var spiCl = newSpiClassLoader()) {
173+
174+
final var elementRegistry = MutableElementRegistry.newDefaultInstance();
175+
final var loader = ElementPathLoader.newDefaultInstance();
176+
final var parent = new PermittedTypesClassLoader();
177+
178+
try {
179+
return loader.load(ElementPathLoader.LoadConfiguration.builder()
180+
.registry(elementRegistry)
181+
.paths(List.of(explodedDirectory))
182+
.parent(parent)
183+
.spiProvider((parentCl, path) -> spiCl)
184+
.build()
185+
).toList();
186+
} finally {
187+
elementRegistry.close();
188+
}
189+
}
190+
}
191+
192+
@Test
193+
public void benchmarkZipFsVsExplodedDirectoryLoad() throws IOException {
194+
195+
// Sanity check both paths actually load the element correctly before trusting any timing numbers.
196+
final var zipFsLoaded = loadFromZipFs();
197+
assertEquals(zipFsLoaded.size(), 1, "zip-fs mode should load exactly one element");
198+
199+
final var explodedLoaded = loadFromExplodedDirectory();
200+
assertEquals(explodedLoaded.size(), 1, "exploded-directory mode should load exactly one element");
201+
202+
assertEquals(
203+
explodedLoaded.get(0).getElementRecord().definition().name(),
204+
zipFsLoaded.get(0).getElementRecord().definition().name(),
205+
"both modes should load the same element"
206+
);
207+
208+
for (int i = 0; i < WARMUP_ITERATIONS; i++) {
209+
loadFromZipFs();
210+
loadFromExplodedDirectory();
211+
}
212+
213+
final var zipFsTimingsMs = new double[MEASURED_ITERATIONS];
214+
final var explodedTimingsMs = new double[MEASURED_ITERATIONS];
215+
216+
for (int i = 0; i < MEASURED_ITERATIONS; i++) {
217+
218+
final var zipFsStart = System.nanoTime();
219+
loadFromZipFs();
220+
zipFsTimingsMs[i] = (System.nanoTime() - zipFsStart) / 1_000_000.0;
221+
222+
final var explodedStart = System.nanoTime();
223+
loadFromExplodedDirectory();
224+
explodedTimingsMs[i] = (System.nanoTime() - explodedStart) / 1_000_000.0;
225+
}
226+
227+
logger.info("zip-fs load times (ms): {}", Arrays.toString(zipFsTimingsMs));
228+
logger.info("exploded load times (ms): {}", Arrays.toString(explodedTimingsMs));
229+
logger.info("zip-fs load avg (ms): {}", average(zipFsTimingsMs));
230+
logger.info("exploded load avg (ms): {}", average(explodedTimingsMs));
231+
}
232+
233+
private static double average(final double[] values) {
234+
var sum = 0.0;
235+
for (final var value : values) {
236+
sum += value;
237+
}
238+
return sum / values.length;
239+
}
240+
241+
}

0 commit comments

Comments
 (0)