Skip to content

Commit c9d5c19

Browse files
authored
Add parallel native library extraction (#23409)
Add an internal chunk-manifest resource format for large native libraries and extract those chunks concurrently. The loader: - preserves conventional single-resource loading and `ai.rapids.cudf.lib-native-dir` - reads up to 12 JAR entries concurrently - writes directly to the pre-sized output with positional `FileChannel` writes - validates manifest structure, exact chunk sizes, and per-chunk CRC32 values - removes partial output after extraction failures Related issue: [NVIDIA/cudf-spark#15145](NVIDIA/cudf-spark#15145) identifies synchronous, single-threaded native JAR extraction as a major executor startup cost. This allows distribution JARs containing 900+ MiB CUDA native libraries to trade a measured 0.0087% increase in archive size for substantially faster startup and JAR creation. NVIDIA/cudf-spark#15356 is the companion producer PR. ### Performance #### Native extraction Measured the exact pre-change and PR paths on the same host with JDK 17 and the same 1,504,051,352-byte CUDA12 `libcudf.so`. Each case ran six extractions in one JVM; the first invocation was treated as warm-up and the remaining five were used for the median. | Path | First invocation | Post-warm-up range | Median | | --- | ---: | ---: | ---: | | Pre-change conventional DEFLATED resource, 16 KiB sequential copy | 6.411 s | 6.370-6.426 s | 6.371 s | | 45 DEFLATED chunks, 12-worker positional extraction | 1.164 s | 1.078-1.124 s | 1.113 s | The post-warm-up median improves by **5.72x**, reducing extraction latency by **82.5%**. The first measured invocation improves by 5.51x. Both paths produced exactly 1,504,051,352 bytes. #### Archive size and creation time Using matched resource trees and forcing the existing `jar:jar@create-parallel-worlds-jar` execution: | Input representation | Median | Output JAR size | | --- | ---: | ---: | | One conventional DEFLATED `libcudf.so` entry | 55.07 s | 952,504,790 bytes | | 45 DEFLATED chunks plus manifest | 5.47 s | 952,587,551 bytes | Chunking increased the archive by 82,761 bytes (**0.0087%**) while making focused JAR creation **10.07x faster**, a **90.1%** wall-time reduction. The full benchmark methodology and measured ranges are in NVIDIA/cudf-spark#15356. ### Validation - `mvn compiler:compile compiler:testCompile` - `mvn surefire:test -Dtest=NativeDepsLoaderExtractionTest` (10 tests) - `mvn surefire:test@native-deps-loader-test` (5 tests) - Cross-repository production JAR benchmark described above The full native Maven lifecycle was not run locally because the installed CMake is 3.28 and this branch requires CMake 4.0; the Java sources and focused tests were compiled and run directly. Authors: - Gera Shegalov (https://github.com/gerashegalov) Approvers: - Zach Puller (https://github.com/zpuller) - Nghia Truong (https://github.com/ttnghia) URL: #23409
1 parent cf63dcd commit c9d5c19

3 files changed

Lines changed: 650 additions & 14 deletions

File tree

java/src/main/java/ai/rapids/cudf/NativeDepsLoader.java

Lines changed: 290 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,37 @@
1313
import java.io.IOException;
1414
import java.io.InputStream;
1515
import java.io.OutputStream;
16+
import java.io.RandomAccessFile;
1617
import java.net.URL;
18+
import java.nio.ByteBuffer;
19+
import java.nio.channels.FileChannel;
1720
import java.util.ArrayList;
1821
import java.util.Arrays;
1922
import java.util.List;
23+
import java.util.Locale;
2024
import java.util.Map;
25+
import java.util.Properties;
2126
import java.util.concurrent.ConcurrentHashMap;
2227
import java.util.concurrent.ExecutionException;
2328
import java.util.concurrent.ExecutorService;
2429
import java.util.concurrent.Executors;
2530
import java.util.concurrent.Future;
31+
import java.util.concurrent.TimeUnit;
2632
import java.util.stream.Collectors;
33+
import java.util.zip.CRC32;
2734

2835
/**
2936
* This class will load the native dependencies.
3037
*/
3138
public class NativeDepsLoader {
3239
private static final Logger log = LoggerFactory.getLogger(NativeDepsLoader.class);
40+
private static final int COPY_BUFFER_SIZE = 1024 * 1024;
41+
// Positional extraction uses one copy buffer per worker.
42+
private static final int MAX_CONCURRENT_CHUNK_READS =
43+
Math.max(1, Math.min(12, Runtime.getRuntime().availableProcessors()));
44+
private static final String CHUNK_MANIFEST_SUFFIX = ".chunks.properties";
45+
private static final String CHUNK_DIRECTORY_SUFFIX = ".chunks/";
46+
private static final String CHUNK_FORMAT_VERSION = "1";
3347

3448
/**
3549
* Set this system property to true to prevent unpacked dependency files from
@@ -285,6 +299,9 @@ private static void loadNativeDeps(String[][] loadOrder, boolean preserveDeps) t
285299
* @throws IOException on any error trying to load the libraries.
286300
*/
287301
public static File loadNativeDep(String depName, boolean preserveDep) throws IOException {
302+
if (libNativeDir != null) {
303+
validateLibNativeDir(new String[]{depName});
304+
}
288305
String os = System.getProperty("os.name");
289306
String arch = System.getProperty("os.arch");
290307
return loadDep(os, arch, depName, preserveDep);
@@ -338,8 +355,8 @@ private static File createFileTimed(String os, String arch, String baseName,
338355
return loc;
339356
}
340357

341-
/** Extract the contents of a library resource into a temporary file */
342-
private static File createFile(String os, String arch, String baseName) throws IOException {
358+
/** Extract the contents of a library resource into a temporary file. */
359+
static File createFile(String os, String arch, String baseName) throws IOException {
343360
String mappedName = System.mapLibraryName(baseName);
344361
// Fast path: when ai.rapids.cudf.lib-native-dir is set, the loader skips
345362
// JAR extraction entirely and uses the pre-unpacked file from the
@@ -354,21 +371,25 @@ private static File createFile(String os, String arch, String baseName) throws I
354371
return loc;
355372
}
356373
String path = arch + "/" + os + "/" + mappedName;
357-
File loc;
358-
URL resource = loader.getResource(path);
359-
if (resource == null) {
374+
URL chunkManifestResource = loader.getResource(path + CHUNK_MANIFEST_SUFFIX);
375+
URL resource = chunkManifestResource == null ? loader.getResource(path) : null;
376+
if (chunkManifestResource == null && resource == null) {
360377
throw new FileNotFoundException("Could not locate native dependency " + path);
361378
}
362379
long t0 = System.currentTimeMillis();
363-
try (InputStream in = resource.openStream()) {
364-
loc = File.createTempFile(baseName, ".so");
365-
loc.deleteOnExit();
366-
try (OutputStream out = new FileOutputStream(loc)) {
367-
byte[] buffer = new byte[1024 * 16];
368-
int read = 0;
369-
while ((read = in.read(buffer)) >= 0) {
370-
out.write(buffer, 0, read);
371-
}
380+
File loc = File.createTempFile(baseName, ".so");
381+
loc.deleteOnExit();
382+
boolean success = false;
383+
try {
384+
if (chunkManifestResource == null) {
385+
extractConventionalResource(resource, loc);
386+
} else {
387+
extractChunkedResource(chunkManifestResource, mappedName, loc);
388+
}
389+
success = true;
390+
} finally {
391+
if (!success && loc.exists() && !loc.delete()) {
392+
log.warn("Could not delete partial native dependency {}", loc);
372393
}
373394
}
374395
if (libLogLoadTiming) {
@@ -379,6 +400,261 @@ private static File createFile(String os, String arch, String baseName) throws I
379400
return loc;
380401
}
381402

403+
private static void extractConventionalResource(URL resource, File loc) throws IOException {
404+
try (InputStream in = resource.openStream();
405+
OutputStream out = new FileOutputStream(loc)) {
406+
copy(in, out, new byte[COPY_BUFFER_SIZE]);
407+
}
408+
}
409+
410+
private static void extractChunkedResource(URL manifestResource, String mappedName, File loc)
411+
throws IOException {
412+
extractChunkedResource(
413+
manifestResource, mappedName, loc, MAX_CONCURRENT_CHUNK_READS);
414+
}
415+
416+
static void extractChunkedResource(URL manifestResource, String mappedName, File loc,
417+
int maxConcurrentReads) throws IOException {
418+
if (maxConcurrentReads <= 0) {
419+
throw new IllegalArgumentException("maxConcurrentReads must be positive");
420+
}
421+
ChunkManifest manifest = ChunkManifest.load(manifestResource);
422+
int concurrentReads = Math.min(maxConcurrentReads, manifest.chunkCount);
423+
ExecutorService executor = Executors.newFixedThreadPool(concurrentReads);
424+
List<Future<?>> chunkFutures = new ArrayList<>(manifest.chunkCount);
425+
try (RandomAccessFile out = new RandomAccessFile(loc, "rw")) {
426+
out.setLength(manifest.librarySize);
427+
FileChannel outputChannel = out.getChannel();
428+
try {
429+
for (int i = 0; i < manifest.chunkCount; i++) {
430+
chunkFutures.add(submitChunkExtraction(
431+
executor, manifestResource, mappedName, manifest, outputChannel, i));
432+
}
433+
executor.shutdown();
434+
awaitChunks(chunkFutures, mappedName);
435+
} finally {
436+
shutdownAndAwait(executor);
437+
}
438+
}
439+
}
440+
441+
private static Future<?> submitChunkExtraction(
442+
ExecutorService executor, URL manifestResource, String mappedName,
443+
ChunkManifest manifest, FileChannel outputChannel, int chunkIndex) throws IOException {
444+
String chunkName = String.format(Locale.ROOT, "%05d", chunkIndex);
445+
URL chunkResource = new URL(
446+
manifestResource, mappedName + CHUNK_DIRECTORY_SUFFIX + chunkName);
447+
long outputOffset = manifest.chunkSize * chunkIndex;
448+
long expectedSize = manifest.expectedChunkSize(chunkIndex);
449+
long expectedCrc32 = manifest.expectedChunkCrc32(chunkIndex);
450+
return executor.submit(() -> {
451+
extractChunk(chunkResource, outputChannel, outputOffset, expectedSize, expectedCrc32);
452+
return null;
453+
});
454+
}
455+
456+
private static void awaitChunks(List<Future<?>> futures, String mappedName)
457+
throws IOException {
458+
IOException failure = null;
459+
for (int i = 0; i < futures.size(); i++) {
460+
try {
461+
futures.get(i).get();
462+
} catch (InterruptedException e) {
463+
Thread.currentThread().interrupt();
464+
throw new IOException(String.format(Locale.ROOT,
465+
"Interrupted while extracting native dependency chunk %s/%05d",
466+
mappedName, i), e);
467+
} catch (ExecutionException e) {
468+
Throwable cause = e.getCause();
469+
IOException chunkFailure = cause instanceof IOException
470+
? (IOException) cause
471+
: new IOException(String.format(Locale.ROOT,
472+
"Could not extract native dependency chunk %s/%05d", mappedName, i), cause);
473+
if (failure == null) {
474+
failure = chunkFailure;
475+
} else {
476+
failure.addSuppressed(chunkFailure);
477+
}
478+
}
479+
}
480+
if (failure != null) {
481+
throw failure;
482+
}
483+
}
484+
485+
private static void shutdownAndAwait(ExecutorService executor) {
486+
executor.shutdownNow();
487+
boolean interrupted = Thread.interrupted();
488+
while (!executor.isTerminated()) {
489+
try {
490+
executor.awaitTermination(1, TimeUnit.SECONDS);
491+
} catch (InterruptedException e) {
492+
interrupted = true;
493+
}
494+
}
495+
if (interrupted) {
496+
Thread.currentThread().interrupt();
497+
}
498+
}
499+
500+
private static void extractChunk(
501+
URL resource, FileChannel outputChannel, long outputOffset,
502+
long expectedSize, long expectedCrc32) throws IOException {
503+
byte[] buffer = new byte[COPY_BUFFER_SIZE];
504+
ByteBuffer bytes = ByteBuffer.wrap(buffer);
505+
CRC32 crc = new CRC32();
506+
long totalBytes = 0;
507+
try (InputStream in = resource.openStream()) {
508+
int read;
509+
while ((read = in.read(buffer)) != -1) {
510+
if (read == 0) {
511+
continue;
512+
}
513+
if (read > expectedSize - totalBytes) {
514+
throw new IOException(String.format(Locale.ROOT,
515+
"Native dependency chunk %s exceeds expected size of %d bytes",
516+
resource, expectedSize));
517+
}
518+
519+
crc.update(buffer, 0, read);
520+
bytes.clear();
521+
bytes.limit(read);
522+
long writeOffset = outputOffset + totalBytes;
523+
while (bytes.hasRemaining()) {
524+
int written = outputChannel.write(bytes, writeOffset);
525+
if (written <= 0) {
526+
throw new IOException("Could not make progress writing native dependency " + resource);
527+
}
528+
writeOffset += written;
529+
}
530+
totalBytes += read;
531+
}
532+
} catch (FileNotFoundException e) {
533+
throw new IOException("Could not locate native dependency chunk " + resource, e);
534+
}
535+
536+
if (totalBytes != expectedSize) {
537+
throw new IOException(String.format(Locale.ROOT,
538+
"Native dependency chunk %s has size %d bytes, expected %d",
539+
resource, totalBytes, expectedSize));
540+
}
541+
if (crc.getValue() != expectedCrc32) {
542+
throw new IOException(String.format(Locale.ROOT,
543+
"Native dependency chunk CRC32 mismatch for %s: expected %08x but extracted %08x",
544+
resource, expectedCrc32, crc.getValue()));
545+
}
546+
}
547+
548+
private static void copy(InputStream in, OutputStream out, byte[] buffer)
549+
throws IOException {
550+
int read;
551+
while ((read = in.read(buffer)) != -1) {
552+
if (read > 0) {
553+
out.write(buffer, 0, read);
554+
}
555+
}
556+
}
557+
558+
private static final class ChunkManifest {
559+
private static final String FORMAT_VERSION_KEY = "format.version";
560+
private static final String LIBRARY_SIZE_KEY = "library.size";
561+
private static final String CHUNK_SIZE_KEY = "chunk.size";
562+
private static final String CHUNK_COUNT_KEY = "chunk.count";
563+
564+
private final long librarySize;
565+
private final long chunkSize;
566+
private final int chunkCount;
567+
private final long[] chunkCrc32;
568+
569+
private ChunkManifest(long librarySize, long chunkSize, int chunkCount, long[] chunkCrc32) {
570+
this.librarySize = librarySize;
571+
this.chunkSize = chunkSize;
572+
this.chunkCount = chunkCount;
573+
this.chunkCrc32 = chunkCrc32;
574+
}
575+
576+
private static ChunkManifest load(URL resource) throws IOException {
577+
Properties properties = new Properties();
578+
try (InputStream in = resource.openStream()) {
579+
properties.load(in);
580+
} catch (IllegalArgumentException e) {
581+
throw new IOException("Malformed native dependency chunk manifest " + resource, e);
582+
}
583+
String version = require(properties, FORMAT_VERSION_KEY, resource);
584+
if (!CHUNK_FORMAT_VERSION.equals(version)) {
585+
throw new IOException("Unsupported native dependency chunk manifest version " + version
586+
+ " in " + resource);
587+
}
588+
long librarySize = parsePositiveLong(properties, LIBRARY_SIZE_KEY, resource);
589+
long chunkSize = parsePositiveLong(properties, CHUNK_SIZE_KEY, resource);
590+
long chunkCountLong = parsePositiveLong(properties, CHUNK_COUNT_KEY, resource);
591+
if (chunkCountLong > Integer.MAX_VALUE) {
592+
throw new IOException("Native dependency chunk count is too large in " + resource);
593+
}
594+
long expectedChunkCount = 1 + ((librarySize - 1) / chunkSize);
595+
if (chunkCountLong != expectedChunkCount) {
596+
throw new IOException(String.format(Locale.ROOT,
597+
"Invalid native dependency chunk count in %s: expected %d but found %d",
598+
resource, expectedChunkCount, chunkCountLong));
599+
}
600+
int chunkCount = (int) chunkCountLong;
601+
long[] chunkCrc32 = new long[chunkCount];
602+
for (int i = 0; i < chunkCount; i++) {
603+
String key = String.format(Locale.ROOT, "chunk.%05d.crc32", i);
604+
chunkCrc32[i] = parseCrc32(properties, key, resource);
605+
}
606+
return new ChunkManifest(librarySize, chunkSize, chunkCount, chunkCrc32);
607+
}
608+
609+
private static long parseCrc32(Properties properties, String key, URL resource)
610+
throws IOException {
611+
String value = require(properties, key, resource);
612+
if (!value.matches("[0-9a-fA-F]{8}")) {
613+
throw new IOException("Invalid " + key + " in " + resource + ": " + value);
614+
}
615+
try {
616+
return Long.parseLong(value, 16);
617+
} catch (NumberFormatException e) {
618+
throw new IOException("Invalid " + key + " in " + resource + ": " + value, e);
619+
}
620+
}
621+
622+
private static long parsePositiveLong(Properties properties, String key, URL resource)
623+
throws IOException {
624+
String value = require(properties, key, resource);
625+
try {
626+
long parsed = Long.parseLong(value);
627+
if (parsed <= 0) {
628+
throw new NumberFormatException("value must be positive");
629+
}
630+
return parsed;
631+
} catch (NumberFormatException e) {
632+
throw new IOException("Invalid " + key + " in " + resource + ": " + value, e);
633+
}
634+
}
635+
636+
private static String require(Properties properties, String key, URL resource)
637+
throws IOException {
638+
String value = properties.getProperty(key);
639+
if (value == null || value.trim().isEmpty()) {
640+
throw new IOException("Missing " + key + " in native dependency chunk manifest "
641+
+ resource);
642+
}
643+
return value.trim();
644+
}
645+
646+
private long expectedChunkSize(int chunkIndex) {
647+
if (chunkIndex == chunkCount - 1) {
648+
return librarySize - (chunkSize * chunkIndex);
649+
}
650+
return chunkSize;
651+
}
652+
653+
private long expectedChunkCrc32(int chunkIndex) {
654+
return chunkCrc32[chunkIndex];
655+
}
656+
}
657+
382658
/**
383659
* Verify that every library named in {@code order} exists as a regular file
384660
* inside {@link #libNativeDir}. Throws {@link IOException} listing the first

0 commit comments

Comments
 (0)