From 20e3c66793aca0541a060d2caac2ad087dd06908 Mon Sep 17 00:00:00 2001 From: Jakob-al28 Date: Wed, 1 Jul 2026 22:10:36 +0000 Subject: [PATCH 1/3] [SYSTEMDS-3929] Speed up Parquet frame reader/writer Rewrites the Parquet frame reader to read columns via parquet's column API (ColumnReadStoreImpl, ColumnReader) instead of creating a Group object per row. On TPC-H lineitem (~30M rows), read time went from 88.9s to 34.5s (2.6x faster). Also rewrites the writer around a custom WriteSupport, removing the per-row Group allocation, adding INT96 timestamp decoding, and fixing the parallel reader to use the sequential implementation instead of reimplementing row iteration per thread. ParquetWriter batches by buffered size internally, so the old writer's manual batch buffer was redundant and has been removed. Compression, dictionary encoding, and row-group size are benchmarked and configurable. The old Group-based implementations are kept under test scope as a benchmark baseline. Adds tests covering null handling, INT96 decoding, and round trips against public Parquet files. --- .../apache/sysds/parser/DMLTranslator.java | 1 + .../sysds/runtime/io/FrameReaderFactory.java | 2 + .../sysds/runtime/io/FrameReaderParquet.java | 192 ++++++++---- .../io/FrameReaderParquetParallel.java | 56 ++-- .../sysds/runtime/io/FrameWriterFactory.java | 2 + .../sysds/runtime/io/FrameWriterParquet.java | 227 ++++++++++----- .../io/FrameWriterParquetParallel.java | 14 +- .../io/parquet/FrameParquetSchemaTest.java | 71 +---- .../io/parquet/FrameReaderParquetLegacy.java | 186 ++++++++++++ .../parquet/FrameReaderWriterParquetTest.java | 134 +++++++++ .../io/parquet/FrameWriterParquetLegacy.java | 212 ++++++++++++++ .../io/parquet/ParquetReaderBenchmark.java | 187 ++++++++++++ .../io/parquet/ParquetTestUtils.java | 85 ++++++ .../io/parquet/ParquetWriterBenchmark.java | 274 ++++++++++++++++++ .../functions/io/parquet/ReadParquetTest.java | 271 +++++++++++++++++ .../io/parquet/WriteParquetTest.java | 165 +++++++++++ .../resources/datasets/parquet/all.parquet | Bin 0 -> 25613 bytes .../datasets/parquet/alltypes_plain.parquet | Bin 0 -> 1851 bytes .../datasets/parquet/userdata1.parquet | Bin 0 -> 113629 bytes 19 files changed, 1862 insertions(+), 217 deletions(-) create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java create mode 100755 src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java create mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java create mode 100644 src/test/resources/datasets/parquet/all.parquet create mode 100644 src/test/resources/datasets/parquet/alltypes_plain.parquet create mode 100644 src/test/resources/datasets/parquet/userdata1.parquet diff --git a/src/main/java/org/apache/sysds/parser/DMLTranslator.java b/src/main/java/org/apache/sysds/parser/DMLTranslator.java index a8e1667d049..0db739cc901 100644 --- a/src/main/java/org/apache/sysds/parser/DMLTranslator.java +++ b/src/main/java/org/apache/sysds/parser/DMLTranslator.java @@ -1058,6 +1058,7 @@ public void constructHops(StatementBlock sb) { case LIBSVM: case HDF5: case DELTA: + case PARQUET: // columnar/text formats: no block layout (blocksize -1) ae.setOutputParams(ae.getDim1(), ae.getDim2(), ae.getNnz(), ae.getUpdateType(), -1); break; diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameReaderFactory.java b/src/main/java/org/apache/sysds/runtime/io/FrameReaderFactory.java index 4e21d2c3f60..6afd3836a15 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameReaderFactory.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameReaderFactory.java @@ -51,6 +51,8 @@ public static FrameReader createFrameReader(FileFormat fmt, FileFormatProperties case PROTO: // TODO performance improvement: add parallel reader return new FrameReaderProto(); + case PARQUET: + return binaryParallel ? new FrameReaderParquetParallel() : new FrameReaderParquet(); default: throw new DMLRuntimeException("Failed to create frame reader for unknown format: " + fmt.toString()); } diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java index ff23e9ea316..504602ef713 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java @@ -20,15 +20,23 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.TimeUnit; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; -import org.apache.parquet.example.data.Group; +import org.apache.parquet.column.ColumnDescriptor; +import org.apache.parquet.column.ColumnReader; +import org.apache.parquet.column.impl.ColumnReadStoreImpl; +import org.apache.parquet.column.page.PageReadStore; import org.apache.parquet.hadoop.ParquetFileReader; -import org.apache.parquet.hadoop.ParquetReader; -import org.apache.parquet.hadoop.example.GroupReadSupport; import org.apache.parquet.hadoop.metadata.ParquetMetadata; import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.io.api.Converter; +import org.apache.parquet.io.api.GroupConverter; +import org.apache.parquet.io.api.PrimitiveConverter; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.PrimitiveType; import org.apache.sysds.common.Types.ValueType; @@ -78,7 +86,7 @@ public FrameBlock readFrameFromHDFS(String fname, ValueType[] schema, String[] n /** * Reads data from a Parquet file on HDFS and fills the provided FrameBlock. * The method retrieves the Parquet schema from the file footer, maps the required column names - * to their corresponding indices, and then uses a ParquetReader to iterate over each row. + * to their corresponding indices, and then uses the column API to iterate over each column. * Data is extracted based on the column type and set into the output FrameBlock. * * @param path The HDFS path to the Parquet file. @@ -89,65 +97,147 @@ public FrameBlock readFrameFromHDFS(String fname, ValueType[] schema, String[] n * @param clen The expected number of columns. */ protected void readParquetFrameFromHDFS(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long rlen, long clen) throws IOException { - // Retrieve schema from Parquet footer - ParquetMetadata metadata = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf)).getFooter(); - MessageType parquetSchema = metadata.getFileMetaData().getSchema(); + int row = readSingleParquetFile(path, conf, dest, clen, 0); - // Map column names to Parquet schema indices - String[] columnNames = dest.getColumnNames(); - int[] columnIndices = new int[columnNames.length]; - for (int i = 0; i < columnNames.length; i++) { - columnIndices[i] = parquetSchema.getFieldIndex(columnNames[i]); + // Check frame dimensions + if (row != rlen) { + throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + row); } + } + + // Constants for decoding legacy INT96 timestamps + private static final int JULIAN_EPOCH_OFFSET_DAYS = 2_440_588; + private static final long MILLIS_IN_DAY = TimeUnit.DAYS.toMillis(1); + private static final long NANOS_PER_MILLISECOND = TimeUnit.MILLISECONDS.toNanos(1); + + /** + * Reads a single Parquet file into the destination FrameBlock using the column API. + * Iterates row groups; within each row group reads each requested column and writes the + * value into the FrameBlock. + * + * @param path The HDFS path to the Parquet file. + * @param conf The Hadoop configuration. + * @param dest The FrameBlock to populate. + * @param clen The number of columns. + * @param rowOffset The starting row offset in the destination FrameBlock. + * @return The number of rows read. + */ + protected int readSingleParquetFile(Path path, Configuration conf, FrameBlock dest, + long clen, long rowOffset) throws IOException + { + String[] columnNames = dest.getColumnNames(); + + try (ParquetFileReader reader = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) { + ParquetMetadata metadata = reader.getFooter(); + MessageType parquetSchema = metadata.getFileMetaData().getSchema(); + String createdBy = metadata.getFileMetaData().getCreatedBy(); + + // Map each requested frame column (by name) to its Parquet column descriptor. + ColumnDescriptor[] descriptors = new ColumnDescriptor[(int) clen]; + for (int col = 0; col < clen; col++) { + ColumnDescriptor desc = parquetSchema.getColumnDescription(new String[]{ columnNames[col] }); + // Nested columns cannot be represented by a flat FrameBlock column + if (desc.getMaxRepetitionLevel() > 0) + throw new IOException("Nested Parquet columns are not supported: " + columnNames[col]); + descriptors[col] = desc; + } + + // no-op converter tree, only used by ColumnReadStoreImpl to resolve a converter per column + GroupConverter rootConverter = newNoOpRootConverter(parquetSchema); + + int row = (int) rowOffset; + PageReadStore pages; + + while (true) { + pages = reader.readNextRowGroup(); - // Read data usind ParquetReader - try (ParquetReader rowReader = ParquetReader.builder(new GroupReadSupport(), path) - .withConf(conf) - .build()) { + if (pages == null) + break; + + int rowsInGroup = (int) pages.getRowCount(); + ColumnReadStoreImpl colStore = new ColumnReadStoreImpl(pages, rootConverter, parquetSchema, createdBy); - Group group; - int row = 0; - while ((group = rowReader.read()) != null) { for (int col = 0; col < clen; col++) { - int colIndex = columnIndices[col]; - if (group.getFieldRepetitionCount(colIndex) > 0) { - PrimitiveType.PrimitiveTypeName type = parquetSchema.getType(columnNames[col]).asPrimitiveType().getPrimitiveTypeName(); - switch (type) { - case INT32: - dest.set(row, col, group.getInteger(colIndex, 0)); - break; - case INT64: - dest.set(row, col, group.getLong(colIndex, 0)); - break; - case FLOAT: - dest.set(row, col, group.getFloat(colIndex, 0)); - break; - case DOUBLE: - dest.set(row, col, group.getDouble(colIndex, 0)); - break; - case BOOLEAN: - dest.set(row, col, group.getBoolean(colIndex, 0)); - break; - case BINARY: - dest.set(row, col, group.getBinary(colIndex, 0).toStringUsingUTF8()); - break; - default: - throw new IOException("Unsupported data type: " + type); - } - } else { - dest.set(row, col, null); - } + ColumnDescriptor desc = descriptors[col]; + ColumnReader creader = colStore.getColumnReader(desc); + int maxDef = desc.getMaxDefinitionLevel(); + PrimitiveType.PrimitiveTypeName ptype = desc.getPrimitiveType().getPrimitiveTypeName(); + readColumn(dest, creader, col, row, rowsInGroup, maxDef, ptype); } - row++; + row += rowsInGroup; } + return row - (int) rowOffset; + } + } - // Check frame dimensions - if (row != rlen) { - throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + row); + /** + * Reads one column of a row group, writing each value (or null) into the destination FrameBlock. + */ + private void readColumn(FrameBlock dest, ColumnReader creader, int col, int rowStart, + int rowsInGroup, int maxDef, PrimitiveType.PrimitiveTypeName ptype) throws IOException + { + for (int i = 0; i < rowsInGroup; i++) { + int row = rowStart + i; + if (creader.getCurrentDefinitionLevel() == maxDef) { + switch (ptype) { + case INT32: + dest.set(row, col, creader.getInteger()); + break; + case INT64: + dest.set(row, col, creader.getLong()); + break; + case FLOAT: + dest.set(row, col, creader.getFloat()); + break; + case DOUBLE: + dest.set(row, col, creader.getDouble()); + break; + case BOOLEAN: + dest.set(row, col, creader.getBoolean()); + break; + case INT96: { + // Legacy INT96 timestamp, narrowed to epoch millis. + // See https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#timestamp + Binary binary = creader.getBinary(); + ByteBuffer buf = ByteBuffer.wrap(binary.getBytes()).order(ByteOrder.LITTLE_ENDIAN); + long nanosOfDay = buf.getLong(); + int julianDay = buf.getInt(); + long millis = (julianDay - JULIAN_EPOCH_OFFSET_DAYS) * MILLIS_IN_DAY + + nanosOfDay / NANOS_PER_MILLISECOND; + dest.set(row, col, millis); + break; + } + case BINARY: + dest.set(row, col, creader.getBinary().toStringUsingUTF8()); + break; + default: + throw new IOException("Unsupported data type: " + ptype); + } + } + else { + dest.set(row, col, null); } + creader.consume(); } } + /** + * Builds a no-op converter tree matching the (flat) Parquet schema. The converter + * callbacks are never invoked because values are read through the typed creader.getX accessors in readColumn(). + * The tree merely serves to satisfy the ColumnReadStoreImpl constructor + */ + private static GroupConverter newNoOpRootConverter(MessageType schema) { + final int n = schema.getFieldCount(); + final PrimitiveConverter[] leaves = new PrimitiveConverter[n]; + for (int i = 0; i < n; i++) + leaves[i] = new PrimitiveConverter() {}; + return new GroupConverter() { + @Override public Converter getConverter(int fieldIndex) { return leaves[fieldIndex]; } + @Override public void start() {} + @Override public void end() {} + }; + } + //not implemented @Override public FrameBlock readFrameFromInputStream(InputStream is, ValueType[] schema, String[] names, long rlen, long clen) diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java index 3d40f53c626..da25157512a 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java @@ -20,6 +20,8 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; @@ -27,9 +29,10 @@ import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; -import org.apache.parquet.example.data.Group; -import org.apache.parquet.hadoop.ParquetReader; -import org.apache.parquet.hadoop.example.GroupReadSupport; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.BlockMetaData; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.hadoop.util.HadoopInputFile; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.hops.OptimizerUtils; import org.apache.sysds.runtime.DMLRuntimeException; @@ -59,14 +62,35 @@ public class FrameReaderParquetParallel extends FrameReaderParquet { protected void readParquetFrameFromHDFS(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long rlen, long clen) throws IOException, DMLRuntimeException { FileSystem fs = IOUtilFunctions.getFileSystem(path); Path[] files = IOUtilFunctions.getSequenceFilePaths(fs, path); + Arrays.sort(files, Comparator.comparing(Path::getName)); int numThreads = Math.min(OptimizerUtils.getParallelBinaryReadParallelism(), files.length); + long[] offsets = new long[files.length]; + long cumulative = 0; + + for (int i = 0; i < files.length; i++) { + long fileRows = 0; + + try (ParquetFileReader reader = + ParquetFileReader.open(HadoopInputFile.fromPath(files[i], conf))) { + + ParquetMetadata meta = reader.getFooter(); + + for (BlockMetaData block : meta.getBlocks()) { + fileRows += block.getRowCount(); + } + } + + offsets[i] = cumulative; + cumulative += fileRows; + } + // Create and execute read tasks ExecutorService pool = CommonThreadPool.get(numThreads); try { List tasks = new ArrayList<>(); - for (Path file : files) { - tasks.add(new ReadFileTask(file, conf, dest, schema, clen)); + for (int i = 0; i < files.length; i++) { + tasks.add(new ReadFileTask(files[i], conf, dest, clen, offsets[i])); } for (Future task : pool.invokeAll(tasks)) { @@ -83,35 +107,21 @@ private class ReadFileTask implements Callable { private Path path; private Configuration conf; private FrameBlock dest; - @SuppressWarnings("unused") - private ValueType[] schema; private long clen; + private long rowOffset; - public ReadFileTask(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long clen) { + public ReadFileTask(Path path, Configuration conf, FrameBlock dest, long clen, long rowOffset) { this.path = path; this.conf = conf; this.dest = dest; - this.schema = schema; this.clen = clen; + this.rowOffset = rowOffset; } // When executed, a ParquetReader for the assigned file opens and iterates over each row processing every column. @Override public Object call() throws Exception { - try (ParquetReader reader = ParquetReader.builder(new GroupReadSupport(), path).withConf(conf).build()) { - Group group; - int row = 0; - while ((group = reader.read()) != null) { - for (int col = 0; col < clen; col++) { - if (group.getFieldRepetitionCount(col) > 0) { - dest.set(row, col, group.getValueToString(col, 0)); - } else { - dest.set(row, col, null); - } - } - row++; - } - } + FrameReaderParquetParallel.super.readSingleParquetFile(path, conf, dest, clen, rowOffset); return null; } } diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameWriterFactory.java b/src/main/java/org/apache/sysds/runtime/io/FrameWriterFactory.java index 3fb3968c96f..6e60af8288b 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameWriterFactory.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameWriterFactory.java @@ -50,6 +50,8 @@ public static FrameWriter createFrameWriter(FileFormat fmt, FileFormatProperties return binaryParallel ? new FrameWriterBinaryBlockParallel() : new FrameWriterBinaryBlock(); case PROTO: return new FrameWriterProto(); + case PARQUET: + return binaryParallel ? new FrameWriterParquetParallel() : new FrameWriterParquet(); default: throw new DMLRuntimeException("Failed to create frame writer for unknown format: " + fmt.toString()); } diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java index ccaeeb56d51..35e457e334d 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java @@ -19,19 +19,23 @@ package org.apache.sysds.runtime.io; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.util.HashMap; +import java.util.Map; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.hadoop.mapred.JobConf; -import org.apache.parquet.example.data.Group; -import org.apache.parquet.example.data.simple.SimpleGroupFactory; +import org.apache.parquet.hadoop.ParquetOutputFormat; import org.apache.parquet.hadoop.ParquetWriter; -import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.hadoop.api.WriteSupport; +import org.apache.parquet.hadoop.metadata.CompressionCodecName; +import org.apache.parquet.io.api.Binary; +import org.apache.parquet.io.api.RecordConsumer; +import org.apache.parquet.schema.LogicalTypeAnnotation; import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.MessageTypeParser; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Types; import org.apache.sysds.conf.ConfigurationManager; import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.frame.data.FrameBlock; @@ -44,6 +48,26 @@ */ public class FrameWriterParquet extends FrameWriter { + public enum DictEncoding { ALL_ON, ALL_OFF, STRING_ONLY } + + private final CompressionCodecName codec; + private final DictEncoding dictEncoding; + private final long rowGroupSize; + + public FrameWriterParquet() { + this(CompressionCodecName.ZSTD, DictEncoding.STRING_ONLY, ParquetWriter.DEFAULT_BLOCK_SIZE); + } + + public FrameWriterParquet(CompressionCodecName codec, DictEncoding dictEncoding) { + this(codec, dictEncoding, ParquetWriter.DEFAULT_BLOCK_SIZE); + } + + public FrameWriterParquet(CompressionCodecName codec, DictEncoding dictEncoding, long rowGroupSize) { + this.codec = codec; + this.dictEncoding = dictEncoding; + this.rowGroupSize = rowGroupSize; + } + /** * Writes a FrameBlock to a Parquet file on HDFS. * @@ -73,7 +97,7 @@ public final void writeFrameToHDFS(FrameBlock src, String fname, long rlen, long /** * Writes the FrameBlock data to a Parquet file using a ParquetWriter. * The method generates a Parquet schema based on the metadata of the FrameBlock, initializes a ParquetWriter with specified configurations, - * iterates over each row and column, adding values (in batches for improved performance) using type-specific conversions. + * iterates over each row and column, writing directly to the RecordConsumer, using type-specific conversions. * * @param path The HDFS path where the Parquet file will be written. * @param conf The Hadoop configuration. @@ -87,70 +111,25 @@ protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock // Create schema based on frame block metadata MessageType schema = createParquetSchema(src); - // TODO:Experiment with different batch sizes? - int batchSize = 1000; - int rowCount = 0; + String[] columnNames = src.getColumnNames(); + ValueType[] columnTypes = src.getSchema(); - // Write data using ParquetWriter //FIXME replace example writer? - try (ParquetWriter writer = ExampleParquetWriter.builder(path) + FrameParquetWriterBuilder writerBuilder = new FrameParquetWriterBuilder(path, schema, src) .withConf(conf) - .withType(schema) - .withCompressionCodec(ParquetWriter.DEFAULT_COMPRESSION_CODEC_NAME) - .withRowGroupSize((long) ParquetWriter.DEFAULT_BLOCK_SIZE) - .withPageSize(ParquetWriter.DEFAULT_PAGE_SIZE) - .withDictionaryEncoding(true) - .build()) - { - - SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema); - - List rowBuffer = new ArrayList<>(batchSize); - - for (int i = 0; i < src.getNumRows(); i++) { - Group group = groupFactory.newGroup(); - for (int j = 0; j < src.getNumColumns(); j++) { - Object value = src.get(i, j); - if (value != null) { - ValueType type = src.getSchema()[j]; - switch (type) { - case STRING: - group.add(src.getColumnNames()[j], value.toString()); - break; - case INT32: - group.add(src.getColumnNames()[j], (int) value); - break; - case INT64: - group.add(src.getColumnNames()[j], (long) value); - break; - case FP32: - group.add(src.getColumnNames()[j], (float) value); - break; - case FP64: - group.add(src.getColumnNames()[j], (double) value); - break; - case BOOLEAN: - group.add(src.getColumnNames()[j], (boolean) value); - break; - default: - throw new IOException("Unsupported value type: " + type); - } - } - } - rowBuffer.add(group); - rowCount++; + .withCompressionCodec(CompressionCodecName.fromConf(conf.get(ParquetOutputFormat.COMPRESSION, codec.name()))) + .withRowGroupSize(conf.getLong(ParquetOutputFormat.BLOCK_SIZE, rowGroupSize)) + .withPageSize(conf.getInt(ParquetOutputFormat.PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE)) + .withDictionaryPageSize(conf.getInt(ParquetOutputFormat.DICTIONARY_PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE)) + .withDictionaryEncoding(conf.getBoolean(ParquetOutputFormat.ENABLE_DICTIONARY, dictEncoding == DictEncoding.ALL_ON)); - if (rowCount >= batchSize) { - for (Group g : rowBuffer) { - writer.write(g); - } - rowBuffer.clear(); - rowCount = 0; - } - } - - for (Group g : rowBuffer) { - writer.write(g); - } + if (dictEncoding == DictEncoding.STRING_ONLY) + for (int j = 0; j < src.getNumColumns(); j++) + if (columnTypes[j] == ValueType.STRING) + writerBuilder = writerBuilder.withDictionaryEncoding(columnNames[j], true); + + try (ParquetWriter writer = writerBuilder.build()) { + for (int i = 0; i < src.getNumRows(); i++) + writer.write(i); } // Delete CRC files created by Hadoop if necessary @@ -164,36 +143,128 @@ protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock * @return The generated Parquet MessageType schema. */ protected MessageType createParquetSchema(FrameBlock src) { - StringBuilder schemaBuilder = new StringBuilder("message FrameSchema {"); String[] columnNames = src.getColumnNames(); ValueType[] columnTypes = src.getSchema(); + Types.MessageTypeBuilder builder = Types.buildMessage(); for (int i = 0; i < src.getNumColumns(); i++) { - schemaBuilder.append("optional "); switch (columnTypes[i]) { case STRING: - schemaBuilder.append("binary ").append(columnNames[i]).append(" (UTF8);"); + builder.optional(PrimitiveTypeName.BINARY) + .as(LogicalTypeAnnotation.stringType()) + .named(columnNames[i]); break; case INT32: - schemaBuilder.append("int32 ").append(columnNames[i]).append(";"); + builder.optional(PrimitiveTypeName.INT32).named(columnNames[i]); break; case INT64: - schemaBuilder.append("int64 ").append(columnNames[i]).append(";"); + builder.optional(PrimitiveTypeName.INT64).named(columnNames[i]); break; case FP32: - schemaBuilder.append("float ").append(columnNames[i]).append(";"); + builder.optional(PrimitiveTypeName.FLOAT).named(columnNames[i]); break; case FP64: - schemaBuilder.append("double ").append(columnNames[i]).append(";"); + builder.optional(PrimitiveTypeName.DOUBLE).named(columnNames[i]); break; case BOOLEAN: - schemaBuilder.append("boolean ").append(columnNames[i]).append(";"); + builder.optional(PrimitiveTypeName.BOOLEAN).named(columnNames[i]); break; default: throw new IllegalArgumentException("Unsupported data type: " + columnTypes[i]); } } - schemaBuilder.append("}"); - return MessageTypeParser.parseMessageType(schemaBuilder.toString()); + return builder.named("FrameSchema"); + } + + /** + * WriteSupport implementation that writes rows from a FrameBlock directly to the + * Parquet RecordConsumer. + */ + private static class FrameWriteSupport extends WriteSupport { + private final MessageType schema; + private final FrameBlock src; + private RecordConsumer recordConsumer; + // constant across all rows + private String[] colNames; + private ValueType[] colTypes; + private int numCols; + + FrameWriteSupport(MessageType schema, FrameBlock src) { + this.schema = schema; + this.src = src; + } + + @Override + public WriteContext init(Configuration configuration) { + Map metadata = new HashMap<>(); + return new WriteContext(schema, metadata); + } + + @Override + public void prepareForWrite(RecordConsumer consumer) { + this.recordConsumer = consumer; + this.colNames = src.getColumnNames(); + this.colTypes = src.getSchema(); + this.numCols = src.getNumColumns(); + } + + @Override + public void write(Integer rowIndex) { + recordConsumer.startMessage(); + for (int j = 0; j < numCols; j++) { + Object value = src.get(rowIndex, j); + if (value != null) { + recordConsumer.startField(colNames[j], j); + switch (colTypes[j]) { + case STRING: + recordConsumer.addBinary(Binary.fromString(value.toString())); + break; + case INT32: + recordConsumer.addInteger((int) value); + break; + case INT64: + recordConsumer.addLong((long) value); + break; + case FP32: + recordConsumer.addFloat((float) value); + break; + case FP64: + recordConsumer.addDouble((double) value); + break; + case BOOLEAN: + recordConsumer.addBoolean((boolean) value); + break; + default: + throw new IllegalArgumentException("Unsupported value type: " + colTypes[j]); + } + recordConsumer.endField(colNames[j], j); + } + } + recordConsumer.endMessage(); + } + } + + /** + * ParquetWriter builder wired to FrameWriteSupport. + */ + private static class FrameParquetWriterBuilder extends ParquetWriter.Builder { + private final MessageType schema; + private final FrameBlock src; + + FrameParquetWriterBuilder(Path path, MessageType schema, FrameBlock src) { + super(path); + this.schema = schema; + this.src = src; + } + + @Override + protected FrameParquetWriterBuilder self() { + return this; + } + + @Override + protected WriteSupport getWriteSupport(Configuration conf) { + return new FrameWriteSupport(schema, src); + } } } diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java index 0ef4431ef47..441ec2db550 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java @@ -35,14 +35,14 @@ import org.apache.sysds.utils.stats.InfrastructureAnalyzer; /** - * Multi-threaded frame parquet reader. + * Multi-threaded frame parquet writer. * */ public class FrameWriterParquetParallel extends FrameWriterParquet { /** * Writes the FrameBlock data to HDFS in parallel. - * The method estimates the number of output partitions by comparing the total number of cells in the FrameBlock with the + * The method estimates the number of output partitions by comparing the estimated output size of the FrameBlock with the * HDFS block size. It then determines the number of threads to use based on the parallelism configuration and the * number of partitions. In case of parallelism, it divides the FrameBlock into chunks and a thread pool is created to * execute a write task for each partition concurrently. @@ -55,14 +55,14 @@ public class FrameWriterParquetParallel extends FrameWriterParquet { protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock src) throws IOException, DMLRuntimeException { - // Estimate number of output partitions - int numPartFiles = Math.max((int) (src.getNumRows() * src.getNumColumns() / InfrastructureAnalyzer.getHDFSBlockSize()), 1); - + // Estimate output partitions from output size in bytes + int numPartFiles = Math.max((int) (OptimizerUtils.estimateSizeExactFrame(src.getNumRows(), src.getNumColumns()) + / InfrastructureAnalyzer.getHDFSBlockSize()), 1); + // Determine parallelism int numThreads = Math.min(OptimizerUtils.getParallelBinaryWriteParallelism(), numPartFiles); - // Fall back to sequential write if numThreads <= 1 - if (numThreads <= 1) { + if (!_forcedParallel && numThreads <= 1) { super.writeParquetFrameToHDFS(path, conf, src); return; } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java index 1e4334891ed..54945f1689a 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java @@ -22,6 +22,7 @@ import org.junit.Assert; import org.junit.Test; +import org.apache.sysds.test.TestUtils; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.runtime.frame.data.FrameBlock; import org.apache.sysds.runtime.io.FrameReader; @@ -76,11 +77,11 @@ public void testParquetWriteReadAllSchemaTypes() { // Populate frame block Object[][] rows = new Object[][] { - { 1.0, 1.1f, 10, 100L, true, "A" }, - { 2.0, 2.1f, 20, 200L, false, "B" }, - { 3.0, 3.1f, 30, 300L, true, "C" }, - { 4.0, 4.1f, 40, 400L, false, "D" }, - { 5.0, 5.1f, 50, 500L, true, "E" } + { 1.0, 1.1f, 10, 100L, true, "A" }, + { 2.0, 2.1f, 20, 200L, false, "B" }, + { 3.0, 3.1f, 30, 300L, true, "C" }, + { 4.0, 4.1f, 40, 400L, false, "D" }, + { 5.0, 5.1f, 50, 500L, true, "E" } }; for (Object[] row : rows) { @@ -115,7 +116,7 @@ public void testParquetWriteReadAllSchemaTypes() { } // Compare the original and the read frame blocks - compareFrameBlocks(fb, fbRead, 1e-6); + TestUtils.compareFrames(fb, fbRead, false); } /** @@ -138,11 +139,11 @@ public void testParquetWriteReadAllSchemaTypesParallel() { FrameBlock fb = new FrameBlock(schema); Object[][] rows = new Object[][] { - { 1.0, 1.1f, 10, 100L, true, "A" }, - { 2.0, 2.1f, 20, 200L, false, "B" }, - { 3.0, 3.1f, 30, 300L, true, "C" }, - { 4.0, 4.1f, 40, 400L, false, "D" }, - { 5.0, 5.1f, 50, 500L, true, "E" } + { 1.0, 1.1f, 10, 100L, true, "A" }, + { 2.0, 2.1f, 20, 200L, false, "B" }, + { 3.0, 3.1f, 30, 300L, true, "C" }, + { 4.0, 4.1f, 40, 400L, false, "D" }, + { 5.0, 5.1f, 50, 500L, true, "E" } }; for (Object[] row : rows) { @@ -172,52 +173,6 @@ public void testParquetWriteReadAllSchemaTypesParallel() { Assert.fail("Failed to read frame block from Parquet (parallel): " + e.getMessage()); } - compareFrameBlocks(fb, fbRead, 1e-6); - } - - private void compareFrameBlocks(FrameBlock expected, FrameBlock actual, double eps) { - Assert.assertEquals("Number of rows mismatch", expected.getNumRows(), actual.getNumRows()); - Assert.assertEquals("Number of columns mismatch", expected.getNumColumns(), actual.getNumColumns()); - - int rows = expected.getNumRows(); - int cols = expected.getNumColumns(); - - for (int i = 0; i < rows; i++) { - for (int j = 0; j < cols; j++) { - Object expVal = expected.get(i, j); - Object actVal = actual.get(i, j); - ValueType vt = expected.getSchema()[j]; - - // Handle nulls first - if(expVal == null || actVal == null) { - Assert.assertEquals("Mismatch at (" + i + "," + j + ")", expVal, actVal); - } else { - switch(vt) { - case FP64: - case FP32: - double dExp = ((Number) expVal).doubleValue(); - double dAct = ((Number) actVal).doubleValue(); - Assert.assertEquals("Mismatch at (" + i + "," + j + ")", dExp, dAct, eps); - break; - case INT32: - case INT64: - long lExp = ((Number) expVal).longValue(); - long lAct = ((Number) actVal).longValue(); - Assert.assertEquals("Mismatch at (" + i + "," + j + ")", lExp, lAct); - break; - case BOOLEAN: - boolean bExp = (Boolean) expVal; - boolean bAct = (Boolean) actVal; - Assert.assertEquals("Mismatch at (" + i + "," + j + ")", bExp, bAct); - break; - case STRING: - Assert.assertEquals("Mismatch at (" + i + "," + j + ")", expVal.toString(), actVal.toString()); - break; - default: - Assert.fail("Unsupported type in comparison: " + vt); - } - } - } - } + TestUtils.compareFrames(fb, fbRead, false); } } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java new file mode 100644 index 00000000000..0b2510e4508 --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java @@ -0,0 +1,186 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sysds.test.functions.io.parquet; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.TimeUnit; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.ParquetReader; +import org.apache.parquet.hadoop.example.GroupReadSupport; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.conf.ConfigurationManager; +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameReader; +import org.apache.sysds.runtime.util.HDFSTool; +import org.apache.parquet.io.api.Binary; + +/** + * Single-threaded frame parquet reader using the {@code Group} record API (legacy baseline). + * + */ +public class FrameReaderParquetLegacy extends FrameReader { + + /** + * Reads a Parquet file from HDFS and converts it into a FrameBlock. + * + * @param fname The HDFS file path to the Parquet file. + * @param schema The expected data types of the columns. + * @param names The names of the columns. + * @param rlen The expected number of rows. + * @param clen The expected number of columns. + * @return A FrameBlock containing the data read from the Parquet file. + */ + @Override + public FrameBlock readFrameFromHDFS(String fname, ValueType[] schema, String[] names, long rlen, long clen) throws IOException, DMLRuntimeException { + // Prepare file access + Configuration conf = ConfigurationManager.getCachedJobConf(); + Path path = new Path(fname); + + // Check existence and non-empty file + if (!HDFSTool.existsFileOnHDFS(path.toString())) { + throw new IOException("File does not exist on HDFS: " + fname); + } + + // Allocate output frame block + ValueType[] lschema = createOutputSchema(schema, clen); + String[] lnames = createOutputNames(names, clen); + FrameBlock ret = createOutputFrameBlock(lschema, lnames, rlen); + + // Read Parquet file + readParquetFrameFromHDFS(path, conf, ret, lschema, rlen, clen); + + return ret; + } + + /** + * Reads data from a Parquet file on HDFS and fills the provided FrameBlock. + * The method retrieves the Parquet schema from the file footer, maps the required column names + * to their corresponding indices, and then uses a ParquetReader to iterate over each row. + * Data is extracted based on the column type and set into the output FrameBlock. + * + * @param path The HDFS path to the Parquet file. + * @param conf The Hadoop configuration. + * @param dest The FrameBlock to populate with data. + * @param schema The expected value types for the output columns. + * @param rlen The expected number of rows. + * @param clen The expected number of columns. + */ + protected void readParquetFrameFromHDFS(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long rlen, long clen) throws IOException { + int row = readSingleParquetFile(path, conf, dest, clen, 0); + + // Check frame dimensions + if (row != rlen) { + throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + row); + } + } + + // Constants for decoding legacy INT96 timestamps + private static final int JULIAN_EPOCH_OFFSET_DAYS = 2_440_588; + private static final long MILLIS_IN_DAY = TimeUnit.DAYS.toMillis(1); + private static final long NANOS_PER_MILLISECOND = TimeUnit.MILLISECONDS.toNanos(1); + + protected int readSingleParquetFile(Path path, Configuration conf, FrameBlock dest, + long clen, long rowOffset) throws IOException { + // Retrieve schema from Parquet footer + ParquetMetadata metadata; + try (ParquetFileReader reader = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) {metadata = reader.getFooter();} + MessageType parquetSchema = metadata.getFileMetaData().getSchema(); + + // Map column names to Parquet schema indices + String[] columnNames = dest.getColumnNames(); + int[] columnIndices = new int[columnNames.length]; + for (int i = 0; i < columnNames.length; i++) { + columnIndices[i] = parquetSchema.getFieldIndex(columnNames[i]); + } + + // Read data usind ParquetReader + try (ParquetReader rowReader = ParquetReader.builder(new GroupReadSupport(), path) + .withConf(conf) + .build()) { + + Group group; + int row = (int) rowOffset; + while ((group = rowReader.read()) != null) { + for (int col = 0; col < clen; col++) { + int colIndex = columnIndices[col]; + if (group.getFieldRepetitionCount(colIndex) > 0) { + PrimitiveType.PrimitiveTypeName type = parquetSchema.getType(columnNames[col]).asPrimitiveType().getPrimitiveTypeName(); + switch (type) { + case INT32: + dest.set(row, col, group.getInteger(colIndex, 0)); + break; + case INT64: + dest.set(row, col, group.getLong(colIndex, 0)); + break; + case FLOAT: + dest.set(row, col, group.getFloat(colIndex, 0)); + break; + case DOUBLE: + dest.set(row, col, group.getDouble(colIndex, 0)); + break; + case BOOLEAN: + dest.set(row, col, group.getBoolean(colIndex, 0)); + break; + case INT96: { + // Legacy INT96 timestamp (Spark/Impala), narrowed to epoch millis. + // See https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#timestamp + Binary binary = group.getInt96(colIndex, 0); + ByteBuffer buf = ByteBuffer.wrap(binary.getBytes()).order(ByteOrder.LITTLE_ENDIAN); + long nanosOfDay = buf.getLong(); + int julianDay = buf.getInt(); + long millis = (julianDay - JULIAN_EPOCH_OFFSET_DAYS) * MILLIS_IN_DAY + + nanosOfDay / NANOS_PER_MILLISECOND; + dest.set(row, col, millis); + break; + } + case BINARY: + dest.set(row, col, group.getBinary(colIndex, 0).toStringUsingUTF8()); + break; + default: + throw new IOException("Unsupported data type: " + type); + } + } else { + dest.set(row, col, null); + } + } + row++; + } + return row - (int) rowOffset; + } + } + + //not implemented + @Override + public FrameBlock readFrameFromInputStream(InputStream is, ValueType[] schema, String[] names, long rlen, long clen) + throws IOException, DMLRuntimeException { + throw new UnsupportedOperationException("Unimplemented method 'readFrameFromInputStream'"); + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java new file mode 100644 index 00000000000..b4d87e9f905 --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sysds.test.functions.io.parquet; + +import static org.junit.Assert.fail; + +import java.io.IOException; + +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameReaderParquet; +import org.apache.sysds.runtime.io.FrameWriterParquet; +import org.apache.sysds.test.TestUtils; +import org.junit.Test; + +/** + * Random-frame write/read round-trip test for the parquet frame reader/writer. + */ +public class FrameReaderWriterParquetTest { + + private static final String FILENAME = "target/testTemp/functions/io/parquet/FrameReaderWriterParquetTest/frame.parquet"; + + // Parquet-supported value types + private static final ValueType[] SCHEMA = { + ValueType.FP64, ValueType.FP32, ValueType.INT32, ValueType.INT64, ValueType.BOOLEAN, ValueType.STRING + }; + + @Test + public void testSingleRowSingleCol() throws IOException { + runWriteReadRoundTrip(1, 1, 4669201); + } + + @Test + public void testSingleRowMultiCol() throws IOException { + runWriteReadRoundTrip(1, 6, 4669201); + } + + @Test + public void testMultiRowSingleCol() throws IOException { + runWriteReadRoundTrip(21, 1, 4669201); + } + + @Test + public void testMultiRowMultiCol() throws IOException { + runWriteReadRoundTrip(42, 5, 4669201); + } + + @Test + public void testLargerFrame() throws IOException { + runWriteReadRoundTrip(694, 6, 4669201); + } + + @Test + public void testValueTypeEdgeCases() throws IOException { + // type min/max, empty string, special chars (comma/quote/newline/unicode) + ValueType[] schema = { ValueType.FP32, ValueType.FP64, ValueType.INT32, ValueType.INT64, + ValueType.BOOLEAN, ValueType.STRING }; + String[] names = { "f32", "f64", "i32", "i64", "b", "s" }; + String[][] data = { + { String.valueOf(Float.MAX_VALUE), String.valueOf(Double.MAX_VALUE), + String.valueOf(Integer.MAX_VALUE), String.valueOf(Long.MAX_VALUE), "true", "" }, + { String.valueOf(-Float.MAX_VALUE), String.valueOf(-Double.MAX_VALUE), + String.valueOf(Integer.MIN_VALUE), String.valueOf(Long.MIN_VALUE), "false", "a,b\"c\nd" }, + { "0.0", "0.0", "0", "0", "true", "unicode_é中" } + }; + FrameBlock in = new FrameBlock(schema, names, data); + + new FrameWriterParquet().writeFrameToHDFS(in, FILENAME, 3, 6); + FrameBlock out = new FrameReaderParquet().readFrameFromHDFS(FILENAME, schema, names, 3, 6); + + TestUtils.compareFrames(in, out, false); + } + + @Test + public void testNullsInStringColumn() throws IOException { + // Numeric columns from String[][] convert null to 0. Only test string nulls here. + // Numeric null round-trips are tested in ReadParquetTest. + ValueType[] schema = { ValueType.STRING, ValueType.STRING }; + String[] names = { "a", "b" }; + String[][] data = { + { "x", "y" }, + { null, null }, + { "p", null } + }; + FrameBlock in = new FrameBlock(schema, names, data); + + new FrameWriterParquet().writeFrameToHDFS(in, FILENAME, 3, 2); + FrameBlock out = new FrameReaderParquet().readFrameFromHDFS(FILENAME, schema, names, 3, 2); + + org.junit.Assert.assertNull(out.get(1, 0)); + org.junit.Assert.assertNull(out.get(1, 1)); + org.junit.Assert.assertNull(out.get(2, 1)); + org.junit.Assert.assertNotNull(out.get(0, 0)); + org.junit.Assert.assertNotNull(out.get(2, 0)); + TestUtils.compareFrames(in, out, false); + } + + private void runWriteReadRoundTrip(int rows, int cols, long seed) throws IOException { + try { + ValueType[] schema = new ValueType[cols]; + for(int i = 0; i < cols; i++) + schema[i] = SCHEMA[i % SCHEMA.length]; + + FrameBlock writeBlock = TestUtils.generateRandomFrameBlock(rows, schema, seed); + + new FrameWriterParquet().writeFrameToHDFS(writeBlock, FILENAME, rows, cols); + FrameBlock readBlock = new FrameReaderParquet() + .readFrameFromHDFS(FILENAME, schema, writeBlock.getColumnNames(), rows, cols); + + TestUtils.compareFrames(writeBlock, readBlock, false); + } + catch(Exception e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java new file mode 100755 index 00000000000..e57e2c99846 --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sysds.test.functions.io.parquet; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.hadoop.mapred.JobConf; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.SimpleGroupFactory; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.schema.LogicalTypeAnnotation; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Types; +import org.apache.sysds.conf.ConfigurationManager; +import org.apache.sysds.runtime.DMLRuntimeException; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameWriter; +import org.apache.sysds.runtime.io.IOUtilFunctions; +import org.apache.sysds.runtime.util.HDFSTool; +import org.apache.sysds.common.Types.ValueType; + +/** + * Single-threaded frame parquet writer. + * + */ +public class FrameWriterParquetLegacy extends FrameWriter { + + private final int batchSizeOverride; + + public FrameWriterParquetLegacy() { + this.batchSizeOverride = 1000; + } + + public FrameWriterParquetLegacy(int batchSize) { + this.batchSizeOverride = batchSize; + } + + /** + * Writes a FrameBlock to a Parquet file on HDFS. + * + * @param src The FrameBlock containing the data to write. + * @param fname The HDFS file path where the Parquet file will be stored. + * @param rlen The expected number of rows. + * @param clen The expected number of columns. + */ + @Override + public final void writeFrameToHDFS(FrameBlock src, String fname, long rlen, long clen) throws IOException, DMLRuntimeException { + // Prepare file access + JobConf conf = ConfigurationManager.getCachedJobConf(); + Path path = new Path(fname); + + // If the file already exists on HDFS, remove it + HDFSTool.deleteFileIfExistOnHDFS(path, conf); + + // Check frame dimensions + if (src.getNumRows() != rlen || src.getNumColumns() != clen) { + throw new IOException("Frame dimensions mismatch with metadata: " + src.getNumRows() + "x" + src.getNumColumns() + " vs " + rlen + "x" + clen + "."); + } + + // Write parquet file + writeParquetFrameToHDFS(path, conf, src); + } + + /** + * Writes the FrameBlock data to a Parquet file using a ParquetWriter. + * The method generates a Parquet schema based on the metadata of the FrameBlock, initializes a ParquetWriter with specified configurations, + * iterates over each row and column, adding values (in batches for improved performance) using type-specific conversions. + * + * @param path The HDFS path where the Parquet file will be written. + * @param conf The Hadoop configuration. + * @param src The FrameBlock containing the data to write. + */ + protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock src) + throws IOException + { + FileSystem fs = IOUtilFunctions.getFileSystem(path, conf); + + // Create schema based on frame block metadata + MessageType schema = createParquetSchema(src); + + int batchSize = batchSizeOverride; + int rowCount = 0; + + // Write data using ParquetWriter //FIXME replace example writer? + try (ParquetWriter writer = ExampleParquetWriter.builder(path) + .withConf(conf) + .withType(schema) + .withCompressionCodec(ParquetWriter.DEFAULT_COMPRESSION_CODEC_NAME) + .withRowGroupSize((long) ParquetWriter.DEFAULT_BLOCK_SIZE) + .withPageSize(ParquetWriter.DEFAULT_PAGE_SIZE) + .withDictionaryEncoding(true) + .build()) + { + + SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema); + + List rowBuffer = new ArrayList<>(batchSize); + + for (int i = 0; i < src.getNumRows(); i++) { + Group group = groupFactory.newGroup(); + for (int j = 0; j < src.getNumColumns(); j++) { + Object value = src.get(i, j); + if (value != null) { + ValueType type = src.getSchema()[j]; + switch (type) { + case STRING: + group.add(src.getColumnNames()[j], value.toString()); + break; + case INT32: + group.add(src.getColumnNames()[j], (int) value); + break; + case INT64: + group.add(src.getColumnNames()[j], (long) value); + break; + case FP32: + group.add(src.getColumnNames()[j], (float) value); + break; + case FP64: + group.add(src.getColumnNames()[j], (double) value); + break; + case BOOLEAN: + group.add(src.getColumnNames()[j], (boolean) value); + break; + default: + throw new IOException("Unsupported value type: " + type); + } + } + } + rowBuffer.add(group); + rowCount++; + + if (rowCount >= batchSize) { + for (Group g : rowBuffer) { + writer.write(g); + } + rowBuffer.clear(); + rowCount = 0; + } + } + + for (Group g : rowBuffer) { + writer.write(g); + } + } + + // Delete CRC files created by Hadoop if necessary + IOUtilFunctions.deleteCrcFilesFromLocalFileSystem(fs, path); + } + + /** + * Creates a Parquet schema based on the metadata of a FrameBlock. + * + * @param src The FrameBlock whose metadata is used to create the Parquet schema. + * @return The generated Parquet MessageType schema. + */ + protected MessageType createParquetSchema(FrameBlock src) { + String[] columnNames = src.getColumnNames(); + ValueType[] columnTypes = src.getSchema(); + Types.MessageTypeBuilder builder = Types.buildMessage(); + + for (int i = 0; i < src.getNumColumns(); i++) { + switch (columnTypes[i]) { + case STRING: + builder.optional(PrimitiveTypeName.BINARY) + .as(LogicalTypeAnnotation.stringType()) + .named(columnNames[i]); + break; + case INT32: + builder.optional(PrimitiveTypeName.INT32).named(columnNames[i]); + break; + case INT64: + builder.optional(PrimitiveTypeName.INT64).named(columnNames[i]); + break; + case FP32: + builder.optional(PrimitiveTypeName.FLOAT).named(columnNames[i]); + break; + case FP64: + builder.optional(PrimitiveTypeName.DOUBLE).named(columnNames[i]); + break; + case BOOLEAN: + builder.optional(PrimitiveTypeName.BOOLEAN).named(columnNames[i]); + break; + default: + throw new IllegalArgumentException("Unsupported data type: " + columnTypes[i]); + } + } + return builder.named("FrameSchema"); + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java new file mode 100644 index 00000000000..592e610185f --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sysds.test.functions.io.parquet; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameReaderParquet; +import org.apache.sysds.runtime.io.FrameWriterParquet; +import org.junit.After; +import org.junit.Assume; + +/** + * Parquet reader benchmark comparing the column-API reader against + * the Group-based reader. + * + * Results report median and min/max across runs and are appended to + * temp/benchmark_results.csv for plotting. + * + * If a dataset is not present the corresponding test is skipped with instructions. + */ +public class ParquetReaderBenchmark { + + private static final String TPCH_FILE = "temp/lineitem.tbl"; + private static final String RESULTS_CSV = "temp/benchmark_results.csv"; + private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_read_bench.parquet"; + private static final int RUNS = 7; + // override on larger machine with -DmaxRows=... (e.g. -DmaxRows=20000000) to test larger row groups. + private static final int MAX_ROWS = Integer.getInteger("maxRows", 2_000_000); + + // TPC-H lineitem schema + private static final ValueType[] LINEITEM_SCHEMA = { + ValueType.INT64, ValueType.INT64, ValueType.INT64, ValueType.INT32, + ValueType.FP64, ValueType.FP64, ValueType.FP64, ValueType.FP64, + ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING, + ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING + }; + private static final String[] LINEITEM_NAMES = { + "orderkey", "partkey", "suppkey", "linenumber", + "quantity", "extendedprice", "discount", "tax", + "returnflag", "linestatus", "shipdate", "commitdate", + "receiptdate", "shipinstruct", "shipmode", "comment" + }; + + private PrintWriter csv; + + @After + public void cleanup() { + new File(TEMP_FILE).delete(); + if (csv != null) csv.close(); + } + + // @Test + public void benchmarkReadTpch() throws Exception { + ReadSpec spec = writeTempParquet(loadLineitemOrSkip()); + runReadBenchmark("read_tpch", spec); + } + + private static final class ReadSpec { + final ValueType[] schema; final String[] names; final int rows; final int cols; + ReadSpec(ValueType[] schema, String[] names, int rows, int cols) { + this.schema = schema; this.names = names; this.rows = rows; this.cols = cols; + } + } + + /** + * Writes a frame to Parquet, then times reading it back with both sequential and parallel readers. + */ + private ReadSpec writeTempParquet(FrameBlock data) throws Exception { + int rows = data.getNumRows(), cols = data.getNumColumns(); + ReadSpec spec = new ReadSpec(data.getSchema(), data.getColumnNames(), rows, cols); + new File(TEMP_FILE).delete(); + new FrameWriterParquet().writeFrameToHDFS(data, TEMP_FILE, rows, cols); + return spec; + } + + /** + * Writes a frame to a Parquet, then benchmarks sequential and parallel reads + */ + private void runReadBenchmark(String category, ReadSpec spec) throws Exception { + final ValueType[] schema = spec.schema; + final String[] names = spec.names; + final int rows = spec.rows, cols = spec.cols; + + FrameBlock probe = new FrameReaderParquet().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols); + org.junit.Assert.assertEquals("Row count mismatch", rows, probe.getNumRows()); + org.junit.Assert.assertEquals("Column count mismatch", cols, probe.getNumColumns()); + probe = null; + + openCsv(); + System.out.println("\n=== Parquet Read Benchmark [" + category + "] (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.printf("%-24s %12s %15s %s%n", "Reader", "Median (ms)", "Rows/sec", "[runs]"); + System.out.println("-".repeat(72)); + + timeRead(category, "Legacy (Group)", () -> + new FrameReaderParquetLegacy().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols), rows); + timeRead(category, "Column API", () -> + new FrameReaderParquet().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols), rows); + System.out.println(); + } + + private interface ReadAction { FrameBlock run() throws Exception; } + + private void timeRead(String category, String label, ReadAction action, int rows) throws Exception { + action.run(); // warmup + + long[] times = new long[RUNS]; + for (int run = 0; run < RUNS; run++) { + long start = System.currentTimeMillis(); + action.run(); + times[run] = System.currentTimeMillis() - start; + } + long med = median(times); + long min = Arrays.stream(times).min().orElse(med); + long max = Arrays.stream(times).max().orElse(med); + System.out.printf("%-24s %12d %15.0f %14s %s%n", label, med, rows * 1000.0 / med, meanStd(times), Arrays.toString(times)); + // columns: benchmark,label,time_ms(median),rows_per_sec,min_ms,max_ms + csv.printf("%s,%s,%d,%.0f,%d,%d%n", category, label, med, rows * 1000.0 / med, min, max); + } + + private static long median(long[] times) { + long[] sorted = times.clone(); + Arrays.sort(sorted); + return sorted[sorted.length / 2]; + } + + private static String meanStd(long[] times) { + double mean = Arrays.stream(times).average().orElse(0); + double var = Arrays.stream(times).mapToDouble(t -> (t - mean) * (t - mean)).average().orElse(0); + return String.format("%.0f+-%.0f ms", mean, Math.sqrt(var)); + } + + private void openCsv() throws Exception { + new File("temp").mkdirs(); + boolean exists = new File(RESULTS_CSV).exists(); + csv = new PrintWriter(new FileWriter(RESULTS_CSV, true)); + if (!exists) + csv.println("benchmark,label,time_ms,rows_per_sec,size_mb,compression_ratio"); + csv.flush(); + } + + private FrameBlock loadLineitemOrSkip() throws Exception { + File f = new File(TPCH_FILE); + if (!f.exists()) { + System.out.println("=== TPC-H read benchmark skipped, dataset not found at " + TPCH_FILE + " ==="); + Assume.assumeTrue("TPC-H dataset not found at " + TPCH_FILE, false); + } + System.out.print("Loading " + f.getPath() + " ... "); + List rows = new ArrayList<>(); + try (BufferedReader br = new BufferedReader(new FileReader(f))) { + String line; + while ((line = br.readLine()) != null && rows.size() < MAX_ROWS) { + if (line.isEmpty()) continue; + if (line.endsWith("|")) line = line.substring(0, line.length() - 1); + rows.add(line.split("\\|", -1)); + } + } + String[][] arr = rows.toArray(new String[0][]); + System.out.println(arr.length + " rows loaded."); + return new FrameBlock(LINEITEM_SCHEMA, LINEITEM_NAMES, arr); + } + +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java new file mode 100644 index 00000000000..da28809333e --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sysds.test.functions.io.parquet; + +import java.io.IOException; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.Path; +import org.apache.parquet.hadoop.ParquetFileReader; +import org.apache.parquet.hadoop.metadata.BlockMetaData; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.hadoop.util.HadoopInputFile; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.conf.ConfigurationManager; + +class ParquetTestUtils { + + static class ParquetMetadataInfo { + String[] names; + ValueType[] schema; + long rlen; + long clen; + } + + static ParquetMetadataInfo inferMetadata(String fname) throws IOException { + Configuration conf = ConfigurationManager.getCachedJobConf(); + Path path = new Path(fname); + + ParquetMetadata metadata; + try (ParquetFileReader r = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) { + metadata = r.getFooter(); + } + MessageType parquetSchema = metadata.getFileMetaData().getSchema(); + + int fieldCount = parquetSchema.getFieldCount(); + String[] names = new String[fieldCount]; + ValueType[] schema = new ValueType[fieldCount]; + + for (int i = 0; i < fieldCount; i++) { + names[i] = parquetSchema.getFieldName(i); + PrimitiveType.PrimitiveTypeName type = parquetSchema.getType(i).asPrimitiveType().getPrimitiveTypeName(); + switch (type) { + case INT32: schema[i] = ValueType.INT32; break; + case INT64: schema[i] = ValueType.INT64; break; + case FLOAT: schema[i] = ValueType.FP32; break; + case DOUBLE: schema[i] = ValueType.FP64; break; + case BOOLEAN: schema[i] = ValueType.BOOLEAN; break; + case BINARY: schema[i] = ValueType.STRING; break; + case INT96: schema[i] = ValueType.INT64; break; + default: + throw new IOException("Unsupported parquet type: " + type + " in column " + names[i]); + } + } + + long rlen = 0; + for (BlockMetaData block : metadata.getBlocks()) + rlen += block.getRowCount(); + + ParquetMetadataInfo info = new ParquetMetadataInfo(); + info.names = names; + info.schema = schema; + info.rlen = rlen; + info.clen = fieldCount; + return info; + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java new file mode 100644 index 00000000000..902931a5adf --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java @@ -0,0 +1,274 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sysds.test.functions.io.parquet; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.parquet.hadoop.metadata.CompressionCodecName; +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameWriterParquet; +import org.apache.sysds.runtime.io.FrameWriterParquet.DictEncoding; +import org.junit.After; +import org.junit.Assume; + +/** + * Parquet writer benchmark using the TPC-H lineitem dataset. + * Writes results to temp/benchmark_results.csv for plotting. + * + * The benchmark methods are disabled by default; uncomment to run manually. + * If a dataset is not present inside the temp dir the corresponding test is skipped with instructions. + * + * The default maxRows=2_000_000, to benchmark row-group + * size properly, run on a machine with enough RAM for a larger frame: + * # 1. Generate a larger TPC-H lineitem into temp/lineitem.tbl + * # 2. Raise the maxRows cap, then run the benchmark: + * mvn test -Dtest=ParquetWriterBenchmark#benchmarkRowGroupSizes \ + * -DmaxRows=60000000 -DargLine="-Xms24g -Xmx24g" -DfailIfNoTests=false + */ +public class ParquetWriterBenchmark { + + private static final String TPCH_FILE = "temp/lineitem.tbl"; + private static final String RESULTS_CSV = "temp/benchmark_results.csv"; + private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_tpch_bench.parquet"; + private static final int RUNS = 3; + private static final int MAX_ROWS = Integer.getInteger("maxRows", 2_000_000); + private static final int[] BATCH_SIZES = { 1, 100, 500, 1000, 5000, 10_000, 50_000, 100_000, 200_000 }; + private static final long[] ROW_GROUP_SIZES = { + 1024 * 1024, // 1 MB + 8L * 1024 * 1024, // 8 MB + 16L * 1024 * 1024, // 16 MB + 32L * 1024 * 1024, // 32 MB + 64L * 1024 * 1024, // 64 MB + 128L * 1024 * 1024, // 128 MB (Parquet default) + 256L * 1024 * 1024, // 256 MB + 512L * 1024 * 1024 // 512 MB + }; + + // TPC-H lineitem schema + private static final ValueType[] LINEITEM_SCHEMA = { + ValueType.INT64, // orderkey + ValueType.INT64, // partkey + ValueType.INT64, // suppkey + ValueType.INT32, // linenumber + ValueType.FP64, // quantity + ValueType.FP64, // extendedprice + ValueType.FP64, // discount + ValueType.FP64, // tax + ValueType.STRING, // returnflag (3 unique values) + ValueType.STRING, // linestatus (2 unique values) + ValueType.STRING, // shipdate + ValueType.STRING, // commitdate + ValueType.STRING, // receiptdate + ValueType.STRING, // shipinstruct (4 unique values) + ValueType.STRING, // shipmode (7 unique values) + ValueType.STRING // comment + }; + + private static final String[] LINEITEM_NAMES = { + "orderkey", "partkey", "suppkey", "linenumber", + "quantity", "extendedprice", "discount", "tax", + "returnflag", "linestatus", "shipdate", "commitdate", + "receiptdate", "shipinstruct", "shipmode", "comment" + }; + + private PrintWriter csv; + + @After + public void cleanup() { + new File(TEMP_FILE).delete(); + if (csv != null) csv.close(); + } + + // @Test + public void benchmarkWriters() throws Exception { + FrameBlock data = loadOrSkip(); + int rows = data.getNumRows(); + + openCsv(); + System.out.println("\n=== TPC-H Writer Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.printf("%-38s %12s %15s%n", "Configuration", "Time (ms)", "Rows/sec"); + System.out.println("-".repeat(70)); + + FrameWriterParquet newWriter = new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_ON); + + // Warmup + new File(TEMP_FILE).delete(); + new FrameWriterParquetLegacy(1000).writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + new File(TEMP_FILE).delete(); + newWriter.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + + for (int batchSize : BATCH_SIZES) { + long[] times = new long[RUNS]; + for (int run = 0; run < RUNS; run++) { + new File(TEMP_FILE).delete(); + long start = System.currentTimeMillis(); + new FrameWriterParquetLegacy(batchSize).writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + times[run] = System.currentTimeMillis() - start; + } + long med = median(times); + String label = "Legacy batchSize=" + batchSize; + System.out.printf("%-38s %12d %15.0f%n", label, med, rows * 1000.0 / med); + csv.printf("batch_sizes,%s,%d,%.0f,,%n", label, med, rows * 1000.0 / med); + } + + System.out.println("-".repeat(70)); + + long[] times = new long[RUNS]; + for (int run = 0; run < RUNS; run++) { + new File(TEMP_FILE).delete(); + long start = System.currentTimeMillis(); + newWriter.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + times[run] = System.currentTimeMillis() - start; + } + long med = median(times); + System.out.printf("%-38s %12d %15.0f%n", "New WriteSupport", med, rows * 1000.0 / med); + csv.printf("batch_sizes,New WriteSupport,%d,%.0f,,%n", med, rows * 1000.0 / med); + System.out.println(); + } + + // @Test + public void benchmarkDictionaryEncoding() throws Exception { + FrameBlock data = loadOrSkip(); + int rows = data.getNumRows(); + + openCsv(); + System.out.println("\n=== TPC-H Dictionary Encoding Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.printf("%-20s %12s %15s%n", "Strategy", "Time (ms)", "Rows/sec"); + System.out.println("-".repeat(52)); + + time("encoding", "ALL_ON", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_ON), data, rows); + time("encoding", "ALL_OFF", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_OFF), data, rows); + time("encoding", "STRING_ONLY", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.STRING_ONLY), data, rows); + System.out.println(); + } + + + // @Test + public void benchmarkRowGroupSizes() throws Exception { + FrameBlock data = loadOrSkip(); + int rows = data.getNumRows(); + + openCsv(); + System.out.println("\n=== TPC-H Row Group Size Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.printf("%-20s %12s %15s%n", "Row Group Size", "Time (ms)", "Rows/sec"); + System.out.println("-".repeat(52)); + + for (long rowGroupSize : ROW_GROUP_SIZES) { + String label = (rowGroupSize / (1024 * 1024)) + "MB"; + time("row_group_sizes", label, + new FrameWriterParquet(CompressionCodecName.ZSTD, DictEncoding.ALL_ON, rowGroupSize), data, rows); + } + System.out.println(); + } + + private FrameBlock loadOrSkip() throws Exception { + File f = new File(TPCH_FILE); + if (!f.exists()) { + System.out.println(); + System.out.println("==================================================="); + System.out.println("TPC-H benchmark skipped, dataset not found"); + System.out.println("To reproduce:"); + System.out.println(" 1. Install DuckDB: https://duckdb.org"); + System.out.println(" 2. Open shell: duckdb"); + System.out.println(" 3. Run in DuckDB: INSTALL tpch;"); + System.out.println(" LOAD tpch;"); + System.out.println(" CALL dbgen(sf=1);"); + System.out.println(" COPY lineitem TO '/temp/lineitem.tbl'"); + System.out.println(" (DELIMITER '|', HEADER false);"); + System.out.println("==================================================="); + Assume.assumeTrue("TPC-H dataset not found at " + TPCH_FILE, false); + } + return loadLineitem(f); + } + + private void openCsv() throws Exception { + new File("temp").mkdirs(); + boolean exists = new File(RESULTS_CSV).exists(); + csv = new PrintWriter(new FileWriter(RESULTS_CSV, true)); + if (!exists) + csv.println("benchmark,label,time_ms,rows_per_sec,size_mb,compression_ratio"); + csv.flush(); + } + + private void time(String category, String label, FrameWriterParquet writer, FrameBlock data, int rows) throws Exception { + new File(TEMP_FILE).delete(); + writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); // warmup + + long[] times = new long[RUNS]; + for (int run = 0; run < RUNS; run++) { + new File(TEMP_FILE).delete(); + long start = System.currentTimeMillis(); + writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + times[run] = System.currentTimeMillis() - start; + } + long med = median(times); + System.out.printf("%-20s %12d %15.0f%n", label, med, rows * 1000.0 / med); + csv.printf("%s,%s,%d,%.0f,,%n", category, label, med, rows * 1000.0 / med); + } + + private long timeWithSize(String category, String label, FrameWriterParquet writer, FrameBlock data, int rows, long baseSize) throws Exception { + new File(TEMP_FILE).delete(); + writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); // warmup + + long[] times = new long[RUNS]; + for (int run = 0; run < RUNS; run++) { + new File(TEMP_FILE).delete(); + long start = System.currentTimeMillis(); + writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + times[run] = System.currentTimeMillis() - start; + } + long med = median(times); + long size = new File(TEMP_FILE).length(); + double mb = size / (1024.0 * 1024.0); + String ratio = baseSize > 0 ? String.format("%.2fx", (double) baseSize / size) : "baseline"; + System.out.printf("%-20s %12d %15.0f %12.2f %10s%n", label, med, rows * 1000.0 / med, mb, ratio); + csv.printf("%s,%s,%d,%.0f,%.2f,%s%n", category, label, med, rows * 1000.0 / med, mb, ratio); + return size; + } + + private static long median(long[] times) { + long[] sorted = times.clone(); + Arrays.sort(sorted); + return sorted[sorted.length / 2]; + } + + private static FrameBlock loadLineitem(File f) throws Exception { + System.out.print("Loading " + f.getPath() + " ... "); + List rows = new ArrayList<>(); + try (BufferedReader br = new BufferedReader(new FileReader(f))) { + String line; + while ((line = br.readLine()) != null && rows.size() < MAX_ROWS) { + if (line.isEmpty()) continue; + if (line.endsWith("|")) line = line.substring(0, line.length() - 1); + rows.add(line.split("\\|", -1)); + } + } + String[][] data = rows.toArray(new String[0][]); + System.out.println(data.length + " rows loaded."); + return new FrameBlock(LINEITEM_SCHEMA, LINEITEM_NAMES, data); + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java new file mode 100644 index 00000000000..c6b4ffe6ef5 --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java @@ -0,0 +1,271 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sysds.test.functions.io.parquet; + +import java.io.File; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; + +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameReaderParquet; +import org.apache.sysds.runtime.io.FrameReaderParquetParallel; +import org.apache.sysds.runtime.io.FrameWriterParquet; +import org.apache.sysds.runtime.io.FrameWriterParquetParallel; +import org.apache.sysds.test.functions.io.parquet.ParquetTestUtils.ParquetMetadataInfo; +import org.apache.sysds.test.TestUtils; +import org.junit.Assert; +import org.junit.Test; + +public class ReadParquetTest { + + private static final String[] FILENAMES = { + "src/test/resources/datasets/parquet/userdata1.parquet", // https://github.com/duckdb/duckdb/blob/main/data/parquet-testing/userdata1.parquet + "src/test/resources/datasets/parquet/alltypes_plain.parquet", // https://github.com/apache/parquet-testing/blob/master/data/alltypes_plain.parquet + "src/test/resources/datasets/parquet/all.parquet" // https://huggingface.co/datasets/cardiffnlp/databench/blob/main/data/002_Titanic/all.parquet + }; + + @Test + public void testReadParquet() throws Exception { + for (String filename : FILENAMES) { + ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + + FrameReaderParquet reader = new FrameReaderParquet(); + FrameBlock frame = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + + Assert.assertEquals("Row count mismatch for " + filename, info.rlen, frame.getNumRows()); + Assert.assertEquals("Column count mismatch for " + filename, info.clen, frame.getNumColumns()); + } + } + + @Test + public void testInt96ColumnsDecodedCorrectly() throws Exception { + assertColumnIsEpochMillis("src/test/resources/datasets/parquet/userdata1.parquet", 0); + assertColumnIsEpochMillis("src/test/resources/datasets/parquet/alltypes_plain.parquet", 10); + } + + private void assertColumnIsEpochMillis(String filename, int colIdx) throws Exception { + ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + FrameReaderParquet reader = new FrameReaderParquet(); + FrameBlock frame = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + int decoded = 0; + for (int r = 0; r < frame.getNumRows(); r++) { + Object val = frame.get(r, colIdx); + if (val == null) continue; + decoded++; + Assert.assertTrue( + "Expected Long (epoch millis) at row " + r + " col " + colIdx + ", got: " + val.getClass().getSimpleName(), + val instanceof Long + ); + } + Assert.assertTrue("No INT96 values were decoded in " + filename, decoded > 0); + } + + @Test + public void testNullHandling() throws Exception { + File temp = Files.createTempFile("systemds_null_parquet", ".parquet").toFile(); + try { + ValueType[] schema = { ValueType.STRING, ValueType.STRING }; + String[] names = { "a", "b" }; + FrameBlock original = new FrameBlock(schema, names, + new String[][] { { "x", "y" }, { null, null }, { "p", "q" } }); + + new FrameWriterParquet().writeFrameToHDFS(original, temp.getPath(), 3, 2); + + FrameBlock result = new FrameReaderParquet() + .readFrameFromHDFS(temp.getPath(), schema, names, 3, 2); + + Assert.assertNotNull("Row 0 col 0 should be non-null", result.get(0, 0)); + Assert.assertNotNull("Row 0 col 1 should be non-null", result.get(0, 1)); + Assert.assertNull("Row 1 col 0 should be null", result.get(1, 0)); + Assert.assertNull("Row 1 col 1 should be null", result.get(1, 1)); + Assert.assertNotNull("Row 2 col 0 should be non-null", result.get(2, 0)); + Assert.assertNotNull("Row 2 col 1 should be non-null", result.get(2, 1)); + } finally { + temp.delete(); + } + + } + + @Test + public void testColumnReaderMatchesLegacy() throws Exception { + for (String filename : FILENAMES) { + ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + + FrameBlock legacy = new FrameReaderParquetLegacy() + .readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + FrameBlock current = new FrameReaderParquet() + .readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + + TestUtils.compareFrames(legacy, current, false); + } + } + + @Test + public void testColumnSubsetProjection() throws Exception { + // request a reordered subset of columns (d, a, c; skip b) + File temp = Files.createTempFile("systemds_subset_parquet", ".parquet").toFile(); + try { + ValueType[] fullSchema = { ValueType.INT64, ValueType.STRING, ValueType.FP64, ValueType.BOOLEAN }; + String[] fullNames = { "a", "b", "c", "d" }; + FrameBlock original = new FrameBlock(fullSchema, fullNames, new String[][] { + { "10", "x", "1.5", "true" }, + { "20", "y", "2.5", "false" }, + { "30", "z", "3.5", "true" } + }); + new FrameWriterParquet().writeFrameToHDFS(original, temp.getPath(), 3, 4); + + ValueType[] subSchema = { ValueType.BOOLEAN, ValueType.INT64, ValueType.FP64 }; + String[] subNames = { "d", "a", "c" }; + + FrameBlock legacy = new FrameReaderParquetLegacy() + .readFrameFromHDFS(temp.getPath(), subSchema, subNames, 3, 3); + FrameBlock current = new FrameReaderParquet() + .readFrameFromHDFS(temp.getPath(), subSchema, subNames, 3, 3); + + TestUtils.compareFrames(legacy, current, false); + + Assert.assertEquals(true, current.get(0, 0)); // d + Assert.assertEquals(10L, current.get(0, 1)); // a + Assert.assertEquals(3.5, ((Number) current.get(2, 2)).doubleValue(), 0.0); // c + } finally { + temp.delete(); + } + } + + @Test + public void testEmptyFile() throws Exception { + File temp = Files.createTempFile("systemds_empty_parquet", ".parquet").toFile(); + try { + ValueType[] schema = { ValueType.INT64, ValueType.STRING }; + String[] names = { "a", "b" }; + FrameBlock empty = new FrameBlock(schema, names, new String[0][]); + new FrameWriterParquet().writeFrameToHDFS(empty, temp.getPath(), 0, 2); + + FrameBlock legacy = new FrameReaderParquetLegacy() + .readFrameFromHDFS(temp.getPath(), schema, names, 0, 2); + FrameBlock current = new FrameReaderParquet() + .readFrameFromHDFS(temp.getPath(), schema, names, 0, 2); + + Assert.assertEquals("Empty file should yield 0 rows (legacy)", 0, legacy.getNumRows()); + Assert.assertEquals("Empty file should yield 0 rows (column API)", 0, current.getNumRows()); + Assert.assertEquals(2, current.getNumColumns()); + } finally { + temp.delete(); + } + } + + @Test + public void testParallelReaderMatchesSequential() throws Exception { + for (String filename : FILENAMES) { + ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + + FrameReaderParquet sequential = new FrameReaderParquet(); + FrameBlock expected = sequential.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + + FrameReaderParquetParallel parallel = new FrameReaderParquetParallel(); + FrameBlock actual = parallel.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + + TestUtils.compareFrames(expected, actual, false); + } + } + + @Test + public void testParallelReaderMultiFileOffsets() throws Exception { + // each file must use its own row range, else the parallel reader overwrites/loses rows + File tempDir = Files.createTempDirectory("systemds_parallel_parquet").toFile(); + try { + ValueType[] schema = { ValueType.STRING, ValueType.INT32 }; + String[] names = { "label", "value" }; + + FrameBlock block1 = new FrameBlock(schema, names, + new String[][] { { "a", "1" }, { "b", "2" }, { "c", "3" } }); + FrameBlock block2 = new FrameBlock(schema, names, + new String[][] { { "d", "4" }, { "e", "5" }, { "f", "6" } }); + + String path1 = tempDir + "/part-0.parquet"; + String path2 = tempDir + "/part-1.parquet"; + FrameWriterParquet writer = new FrameWriterParquet(); + writer.writeFrameToHDFS(block1, path1, 3, 2); + writer.writeFrameToHDFS(block2, path2, 3, 2); + + FrameReaderParquet seq = new FrameReaderParquet(); + Set expectedLabels = new HashSet<>(); + String[] parts = { path1, path2 }; + for (String p : parts) { + FrameBlock fb = seq.readFrameFromHDFS(p, schema, names, 3, 2); + for (int r = 0; r < fb.getNumRows(); r++) + expectedLabels.add((String) fb.get(r, 0)); + } + + FrameReaderParquetParallel parallel = new FrameReaderParquetParallel(); + FrameBlock result = parallel.readFrameFromHDFS(tempDir.toString(), schema, names, 6, 2); + + Assert.assertEquals("Expected 6 total rows", 6, result.getNumRows()); + + Set actualLabels = new HashSet<>(); + for (int r = 0; r < result.getNumRows(); r++) { + Object label = result.get(r, 0); + Assert.assertNotNull("Row " + r + " is null, row-offset bug suspected", label); + actualLabels.add((String) label); + } + Assert.assertEquals("Parallel result does not match sequential ground truth", expectedLabels, actualLabels); + } finally { + for (File f : tempDir.listFiles()) + f.delete(); + tempDir.delete(); + } + } + + @Test + public void testParallelWriterRoundTrip() throws Exception { + File tempDir = Files.createTempDirectory("systemds_parallel_write").toFile(); + try { + ValueType[] schema = { ValueType.INT64, ValueType.STRING, ValueType.FP64 }; + String[] names = { "id", "name", "val" }; + String[][] data = new String[20][]; + for (int i = 0; i < 20; i++) + data[i] = new String[] { String.valueOf(i), "row" + i, String.valueOf(i + 0.5) }; + FrameBlock original = new FrameBlock(schema, names, data); + + FrameWriterParquetParallel writer = new FrameWriterParquetParallel(); + writer.setForcedParallel(true); + writer.writeFrameToHDFS(original, tempDir.getPath(), 20, 3); + + FrameBlock result = new FrameReaderParquetParallel() + .readFrameFromHDFS(tempDir.getPath(), schema, names, 20, 3); + + Assert.assertEquals("Row count mismatch after parallel write", 20, result.getNumRows()); + // Rows may be out of order in parallel reads, validate by comparing tuples, not row positions + Set expected = new HashSet<>(); + for (int i = 0; i < 20; i++) + expected.add(i + "|row" + i + "|" + (i + 0.5)); + Set actual = new HashSet<>(); + for (int r = 0; r < result.getNumRows(); r++) + actual.add(result.get(r, 0) + "|" + result.get(r, 1) + "|" + result.get(r, 2)); + Assert.assertEquals("Parallel-written data does not round-trip", expected, actual); + } finally { + for (File f : tempDir.listFiles()) + f.delete(); + tempDir.delete(); + } + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java new file mode 100644 index 00000000000..576253a754d --- /dev/null +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java @@ -0,0 +1,165 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sysds.test.functions.io.parquet; + +import java.io.File; + +import org.apache.sysds.test.TestUtils; +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.frame.data.columns.Array; +import org.apache.sysds.runtime.frame.data.columns.ArrayFactory; +import org.apache.sysds.runtime.io.FrameReaderParquet; +import org.apache.sysds.runtime.io.FrameReaderParquetParallel; +import org.apache.sysds.runtime.io.FrameWriterParquet; +import org.apache.sysds.runtime.io.FrameWriterParquetParallel; +import org.apache.sysds.test.functions.io.parquet.ParquetTestUtils.ParquetMetadataInfo; +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; + +public class WriteParquetTest { + + private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_write_parquet_test.parquet"; + private static final String TEMP_PAR_PATH = System.getProperty("java.io.tmpdir") + "/systemds_write_parquet_test_par"; + + @After + public void cleanup() { + new File(TEMP_FILE).delete(); + deleteRecursive(new File(TEMP_PAR_PATH)); + } + + private static void deleteRecursive(File f) { + if (f.isDirectory()) + for (File c : f.listFiles()) deleteRecursive(c); + f.delete(); + } + + @Test + public void testRoundtripPublicFiles() throws Exception { + String[] files = { + "src/test/resources/datasets/parquet/userdata1.parquet", + "src/test/resources/datasets/parquet/alltypes_plain.parquet", + "src/test/resources/datasets/parquet/all.parquet" + }; + + for (String filename : files) { + ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + + FrameReaderParquet reader = new FrameReaderParquet(); + FrameBlock original = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + + FrameWriterParquet writer = new FrameWriterParquet(); + writer.writeFrameToHDFS(original, TEMP_FILE, original.getNumRows(), original.getNumColumns()); + + FrameBlock result = reader.readFrameFromHDFS(TEMP_FILE, info.schema, info.names, info.rlen, info.clen); + + TestUtils.compareFrames(original, result, false); + } + } + + @Test + public void testMultiPartFileRoundtrip() throws Exception { + // Create two parquet part files and verify that the parallel reader + // reads both files correctly and combines them into the expected result. + ValueType[] schema = { + ValueType.STRING, + ValueType.INT32, + ValueType.INT64, + ValueType.FP32, + ValueType.FP64, + ValueType.BOOLEAN + }; + String[] names = { "name", "age", "id", "score", "ratio", "active" }; + String[][] data = { + { "Alice", "30", "1000", "1.5", "0.75", "true" }, + { "Bob", "25", "2000", "2.5", "0.50", "false" }, + { "Carol", "40", "3000", "3.5", "0.25", "true" }, + { "Dave", "35", "4000", "4.5", "0.10", "false" }, + { "Eve", "28", "5000", "5.5", "0.90", "true" }, + { "Frank", "45", "6000", "6.5", "0.60", "false" } + }; + FrameBlock original = new FrameBlock(schema, names, data); + + new File(TEMP_PAR_PATH).mkdir(); + FrameWriterParquet writer = new FrameWriterParquet(); + writer.writeFrameToHDFS(original.slice(0, 2), TEMP_PAR_PATH + "/part-0.parquet", 3, schema.length); + writer.writeFrameToHDFS(original.slice(3, 5), TEMP_PAR_PATH + "/part-1.parquet", 3, schema.length); + + FrameBlock result = new FrameReaderParquetParallel() + .readFrameFromHDFS(TEMP_PAR_PATH, schema, names, 6, schema.length); + + TestUtils.compareFrames(original, result, false); + } + + @Test + public void testParallelRoundtrip() throws Exception { + ValueType[] schema = { + ValueType.STRING, + ValueType.INT32, + ValueType.INT64, + ValueType.FP32, + ValueType.FP64, + ValueType.BOOLEAN + }; + String[] names = { "name", "age", "id", "score", "ratio", "active" }; + String[][] data = { + { "Alice", "30", "1000", "1.5", "0.75", "true" }, + { "Bob", "25", "2000", "2.5", "0.50", "false" }, + { "Carol", "40", "3000", "3.5", "0.25", "true" } + }; + FrameBlock original = new FrameBlock(schema, names, data); + + new FrameWriterParquetParallel().writeFrameToHDFS(original, TEMP_PAR_PATH, original.getNumRows(), original.getNumColumns()); + FrameBlock result = new FrameReaderParquetParallel().readFrameFromHDFS(TEMP_PAR_PATH, schema, names, original.getNumRows(), original.getNumColumns()); + + TestUtils.compareFrames(original, result, false); + } + + @Test + public void testRoundtrip() throws Exception { + ValueType[] schema = { + ValueType.STRING, + ValueType.INT32, + ValueType.INT64, + ValueType.FP32, + ValueType.FP64, + ValueType.BOOLEAN + }; + String[] names = { "name test", "age", "id", "score", "ratio", "active" }; + String[][] data = { + { "Alice", "30", "1000", "1.5", "0.75", "true" }, + { "Bob", "25", "2000", "2.5", "0.50", "false" }, + { "Carol", "40", "3000", "3.5", "0.25", "true" } + }; + + FrameBlock original = new FrameBlock(schema, names, data); + + // Write + FrameWriterParquet writer = new FrameWriterParquet(); + writer.writeFrameToHDFS(original, TEMP_FILE, original.getNumRows(), original.getNumColumns()); + + // Read back + FrameReaderParquet reader = new FrameReaderParquet(); + FrameBlock result = reader.readFrameFromHDFS(TEMP_FILE, schema, names, original.getNumRows(), original.getNumColumns()); + + TestUtils.compareFrames(original, result, false); + } +} diff --git a/src/test/resources/datasets/parquet/all.parquet b/src/test/resources/datasets/parquet/all.parquet new file mode 100644 index 0000000000000000000000000000000000000000..e139fcd00cc4ec10df6bd15991318db4380499c5 GIT binary patch literal 25613 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 E86*m+KL7v# literal 0 HcmV?d00001 diff --git a/src/test/resources/datasets/parquet/alltypes_plain.parquet b/src/test/resources/datasets/parquet/alltypes_plain.parquet new file mode 100644 index 0000000000000000000000000000000000000000..a63f5dca7c3821909748f34752966a0d7e08d47f GIT binary patch literal 1851 zcmb7F&ui0g6#pje+AM2l7<*qVv_zM;YQD;X!1G_`Ye@W}TbqmweOL$NPTX=lg!8G{0y<66Rp8 z2pS|Aqlfj;PSH-&mT4zwizU$p1|0Y`VGJoyS_YbwNId;`jBg|z+DN8!b`S=|Sr$Ee51+|8u<)E>*X#arx$Xz24Q}8O`6X`}Xhr%F1Zjm_hG6In z7b&rg?-Cs*15K~?$g4Hmpi6uSk7fKqck31Rd$NO@X;dxW?*`sZ;$!02EAVEj1Dx*0 z{MLuNloZ0uLp~A&5eQYhXi-eh3&y9k4#_aQs_m^t;cL8xuhMu#`94GW^Wlpd7r_2h zbWlRre%G&Crz3oz;A@fee~~T(>&n~(=)0;8>IvyeeZ%&hb^-l_Dsj zFvuM<3gd=3Zp;SqWJI2b$YdaF$o()3pD7?YQ98!mj1HO5|D}r6be0>LFTr05hN163Gw zN`^s(6x}&&X(N%RnM7u%??nSFr{~`PZ?MG}V6i6>#vU;kXJ%l`=Er#5j4|7?$M(UP z-GIH6Am3BD#zq&s>YC+S`G?MW!>iZw=2&6OxPE8h?#;!8`C@+5-thcNe#V-dsZ?y! oaow58so4p;;FgVPyFBeqnNHc9FdWl$-SX^Jc0`|z5`8xR0vs|-V*mgE literal 0 HcmV?d00001 diff --git a/src/test/resources/datasets/parquet/userdata1.parquet b/src/test/resources/datasets/parquet/userdata1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2ae23dac0ffe794068f26c163b7245b3509f31c5 GIT binary patch literal 113629 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 DbfIxZ literal 0 HcmV?d00001 From e0916e16e0343e14e6b6375a4e48d305acaf0ad0 Mon Sep 17 00:00:00 2001 From: Jakob-al28 Date: Thu, 2 Jul 2026 09:41:59 +0000 Subject: [PATCH 2/3] Generate parquet test files with Spark --- .../io/parquet/ParquetTestUtils.java | 133 ++++++++++++++++++ .../functions/io/parquet/ReadParquetTest.java | 31 +++- .../io/parquet/WriteParquetTest.java | 35 +++-- .../resources/datasets/parquet/all.parquet | Bin 25613 -> 0 bytes .../datasets/parquet/alltypes_plain.parquet | Bin 1851 -> 0 bytes .../datasets/parquet/userdata1.parquet | Bin 113629 -> 0 bytes 6 files changed, 182 insertions(+), 17 deletions(-) delete mode 100644 src/test/resources/datasets/parquet/all.parquet delete mode 100644 src/test/resources/datasets/parquet/alltypes_plain.parquet delete mode 100644 src/test/resources/datasets/parquet/userdata1.parquet diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java index da28809333e..9ce52887cee 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java @@ -19,7 +19,15 @@ package org.apache.sysds.test.functions.io.parquet; +import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; @@ -29,8 +37,18 @@ import org.apache.parquet.hadoop.util.HadoopInputFile; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.PrimitiveType; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.conf.ConfigurationManager; +import org.apache.sysds.test.AutomatedTestBase; class ParquetTestUtils { @@ -82,4 +100,119 @@ static ParquetMetadataInfo inferMetadata(String fname) throws IOException { info.clen = fieldCount; return info; } + + /** + * Generates the public test files (userdata1, alltypes_plain, all) with Spark's + * DataFrameWriter. userdata1 and alltypes_plain each include a TimestampType column, + * which Spark 3.5 encodes as INT96 by default. + * + * @param outDir directory the generated files are written into + * @return map from file name (e.g. "userdata1") to its generated file path + */ + static Map generatePublicTestFiles(File outDir) throws Exception { + SparkSession spark = AutomatedTestBase.createSystemDSSparkSession("parquet-test-files", "local[1]"); + + Map files = new LinkedHashMap<>(); + files.put("userdata1", writeTestFile(spark, outDir, "userdata1", userdata1Rows(), userdata1Schema())); + files.put("alltypes_plain", writeTestFile(spark, outDir, "alltypes_plain", alltypesPlainRows(), alltypesPlainSchema())); + files.put("all", writeTestFile(spark, outDir, "all", allRows(), allSchema())); + return files; + } + + private static StructType userdata1Schema() { + return new StructType(new StructField[] { + new StructField("registration_dttm", DataTypes.TimestampType, true, Metadata.empty()), + new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("first_name", DataTypes.StringType, true, Metadata.empty()), + new StructField("salary", DataTypes.DoubleType, true, Metadata.empty()), + }); + } + + private static List userdata1Rows() { + return Arrays.asList( + RowFactory.create(Timestamp.valueOf("2016-02-03 07:55:29"), 1, "Amanda", 49756.53), + RowFactory.create(Timestamp.valueOf("2016-02-03 17:04:03"), 2, "Albert", 150280.17), + RowFactory.create(Timestamp.valueOf("2016-02-03 01:09:31"), 3, "Evelyn", 144972.51), + RowFactory.create((Timestamp) null, 4, "Denise", 90263.05), + RowFactory.create(Timestamp.valueOf("2016-02-03 05:07:25"), 5, "Carlos", 75500.34) + ); + } + + private static StructType alltypesPlainSchema() { + return new StructType(new StructField[] { + new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("bool_col", DataTypes.BooleanType, true, Metadata.empty()), + new StructField("tinyint_col", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("smallint_col", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("bigint_col", DataTypes.LongType, true, Metadata.empty()), + new StructField("float_col", DataTypes.FloatType, true, Metadata.empty()), + new StructField("double_col", DataTypes.DoubleType, true, Metadata.empty()), + new StructField("date_string_col", DataTypes.StringType, true, Metadata.empty()), + new StructField("string_col", DataTypes.StringType, true, Metadata.empty()), + new StructField("timestamp_col", DataTypes.TimestampType, true, Metadata.empty()), + }); + } + + private static List alltypesPlainRows() { + return Arrays.asList( + RowFactory.create(1, true, 1, 10, 100L, 1.5f, 2.25, "03/01/09", "row-1", Timestamp.valueOf("2009-03-01 00:00:00")), + RowFactory.create(2, false, 2, 20, 200L, 2.5f, 4.5, "03/02/09", "row-2", Timestamp.valueOf("2009-03-02 05:15:30")), + RowFactory.create(3, true, 3, 30, 300L, 3.5f, 6.75, "03/03/09", "row-3", Timestamp.valueOf("2009-03-03 10:30:00")), + RowFactory.create(4, false, 4, 40, 400L, 4.5f, 9.0, "03/04/09", "row-4", Timestamp.valueOf("2009-03-04 15:45:15")), + RowFactory.create(5, true, 5, 50, 500L, 5.5f, 11.25, "03/05/09", "row-5", Timestamp.valueOf("2009-03-05 21:00:45")), + RowFactory.create(6, false, 6, 60, 600L, 6.5f, 13.5, "03/06/09", "row-6", Timestamp.valueOf("2009-03-06 02:20:10")), + RowFactory.create(7, true, 7, 70, 700L, 7.5f, 15.75, "03/07/09", "row-7", Timestamp.valueOf("2009-03-07 08:35:50")), + RowFactory.create(8, false, 8, 80, 800L, 8.5f, 18.0, "03/08/09", "row-8", Timestamp.valueOf("2009-03-08 13:50:25")) + ); + } + + private static StructType allSchema() { + return new StructType(new StructField[] { + new StructField("PassengerId", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("Survived", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("Pclass", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("Name", DataTypes.StringType, true, Metadata.empty()), + new StructField("Sex", DataTypes.StringType, true, Metadata.empty()), + new StructField("Age", DataTypes.DoubleType, true, Metadata.empty()), + new StructField("Fare", DataTypes.DoubleType, true, Metadata.empty()), + new StructField("Embarked", DataTypes.StringType, true, Metadata.empty()), + }); + } + + private static List allRows() { + return Arrays.asList( + RowFactory.create(1, 0, 3, "Braund, Mr. Owen Harris", "male", 22.0, 7.25, "S"), + RowFactory.create(2, 1, 1, "Cumings, Mrs. John Bradley", "female", 38.0, 71.2833, "C"), + RowFactory.create(3, 1, 3, "Heikkinen, Miss. Laina", "female", 26.0, 7.925, "S"), + RowFactory.create(4, 1, 1, "Futrelle, Mrs. Jacques Heath", "female", 35.0, 53.1, "S"), + RowFactory.create(5, 0, 3, "Allen, Mr. William Henry", "male", null, 8.05, "S"), + RowFactory.create(6, 0, 3, "Moran, Mr. James", "male", null, 8.4583, "Q"), + RowFactory.create(7, 0, 1, "McCarthy, Mr. Timothy J", "male", 54.0, 51.8625, "S"), + RowFactory.create(8, 0, 3, "Palsson, Master. Gosta Leonard", "male", 2.0, 21.075, "S") + ); + } + + // Spark writes a directory of part files, so we force one partition and rename it to a single file. + private static String writeTestFile(SparkSession spark, File outDir, String name, List rows, StructType schema) throws IOException { + Dataset df = spark.createDataFrame(rows, schema); + File tmpDir = new File(outDir, "_tmp_" + name); + df.coalesce(1).write().mode(SaveMode.Overwrite).parquet(tmpDir.getPath()); + + File[] parts = tmpDir.listFiles((d, n) -> n.startsWith("part-") && n.endsWith(".parquet")); + if (parts == null || parts.length != 1) + throw new IOException("expected exactly 1 part file in " + tmpDir); + + File dest = new File(outDir, name + ".parquet"); + Files.copy(parts[0].toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + deleteRecursive(tmpDir); + return dest.getPath(); + } + + private static void deleteRecursive(File f) { + File[] children = f.listFiles(); + if (children != null) + for (File c : children) + deleteRecursive(c); + f.delete(); + } } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java index c6b4ffe6ef5..4c7f2ccae2e 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java @@ -22,6 +22,7 @@ import java.io.File; import java.nio.file.Files; import java.util.HashSet; +import java.util.Map; import java.util.Set; import org.apache.sysds.common.Types.ValueType; @@ -32,16 +33,32 @@ import org.apache.sysds.runtime.io.FrameWriterParquetParallel; import org.apache.sysds.test.functions.io.parquet.ParquetTestUtils.ParquetMetadataInfo; import org.apache.sysds.test.TestUtils; +import org.junit.AfterClass; import org.junit.Assert; +import org.junit.BeforeClass; import org.junit.Test; public class ReadParquetTest { - private static final String[] FILENAMES = { - "src/test/resources/datasets/parquet/userdata1.parquet", // https://github.com/duckdb/duckdb/blob/main/data/parquet-testing/userdata1.parquet - "src/test/resources/datasets/parquet/alltypes_plain.parquet", // https://github.com/apache/parquet-testing/blob/master/data/alltypes_plain.parquet - "src/test/resources/datasets/parquet/all.parquet" // https://huggingface.co/datasets/cardiffnlp/databench/blob/main/data/002_Titanic/all.parquet - }; + // Generated once per test class with Spark's DataFrameWriter + private static File testFileDir; + private static String[] FILENAMES; + + @BeforeClass + public static void generateTestFiles() throws Exception { + testFileDir = Files.createTempDirectory("systemds_parquet_public_test_files").toFile(); + Map files = ParquetTestUtils.generatePublicTestFiles(testFileDir); + FILENAMES = new String[] { files.get("userdata1"), files.get("alltypes_plain"), files.get("all") }; + } + + @AfterClass + public static void cleanupTestFiles() { + File[] children = testFileDir.listFiles(); + if (children != null) + for (File f : children) + f.delete(); + testFileDir.delete(); + } @Test public void testReadParquet() throws Exception { @@ -58,8 +75,8 @@ public void testReadParquet() throws Exception { @Test public void testInt96ColumnsDecodedCorrectly() throws Exception { - assertColumnIsEpochMillis("src/test/resources/datasets/parquet/userdata1.parquet", 0); - assertColumnIsEpochMillis("src/test/resources/datasets/parquet/alltypes_plain.parquet", 10); + assertColumnIsEpochMillis(FILENAMES[0], 0); // userdata1: registration_dttm + assertColumnIsEpochMillis(FILENAMES[1], 9); // alltypes_plain: timestamp_col } private void assertColumnIsEpochMillis(String filename, int colIdx) throws Exception { diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java index 576253a754d..e8a6c5b3599 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java @@ -20,19 +20,20 @@ package org.apache.sysds.test.functions.io.parquet; import java.io.File; +import java.nio.file.Files; +import java.util.Map; import org.apache.sysds.test.TestUtils; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.runtime.frame.data.FrameBlock; -import org.apache.sysds.runtime.frame.data.columns.Array; -import org.apache.sysds.runtime.frame.data.columns.ArrayFactory; import org.apache.sysds.runtime.io.FrameReaderParquet; import org.apache.sysds.runtime.io.FrameReaderParquetParallel; import org.apache.sysds.runtime.io.FrameWriterParquet; import org.apache.sysds.runtime.io.FrameWriterParquetParallel; import org.apache.sysds.test.functions.io.parquet.ParquetTestUtils.ParquetMetadataInfo; import org.junit.After; -import org.junit.Assert; +import org.junit.AfterClass; +import org.junit.BeforeClass; import org.junit.Test; public class WriteParquetTest { @@ -40,6 +41,26 @@ public class WriteParquetTest { private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_write_parquet_test.parquet"; private static final String TEMP_PAR_PATH = System.getProperty("java.io.tmpdir") + "/systemds_write_parquet_test_par"; + // See ParquetTestUtils.generatePublicTestFiles(): these are generated with Spark's DataFrameWriter + private static File testFileDir; + private static String[] PUBLIC_FILES; + + @BeforeClass + public static void generateTestFiles() throws Exception { + testFileDir = Files.createTempDirectory("systemds_parquet_public_test_files").toFile(); + Map files = ParquetTestUtils.generatePublicTestFiles(testFileDir); + PUBLIC_FILES = new String[] { files.get("userdata1"), files.get("alltypes_plain"), files.get("all") }; + } + + @AfterClass + public static void cleanupTestFiles() { + File[] children = testFileDir.listFiles(); + if (children != null) + for (File f : children) + f.delete(); + testFileDir.delete(); + } + @After public void cleanup() { new File(TEMP_FILE).delete(); @@ -54,13 +75,7 @@ private static void deleteRecursive(File f) { @Test public void testRoundtripPublicFiles() throws Exception { - String[] files = { - "src/test/resources/datasets/parquet/userdata1.parquet", - "src/test/resources/datasets/parquet/alltypes_plain.parquet", - "src/test/resources/datasets/parquet/all.parquet" - }; - - for (String filename : files) { + for (String filename : PUBLIC_FILES) { ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); FrameReaderParquet reader = new FrameReaderParquet(); diff --git a/src/test/resources/datasets/parquet/all.parquet b/src/test/resources/datasets/parquet/all.parquet deleted file mode 100644 index e139fcd00cc4ec10df6bd15991318db4380499c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25613 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 E86*m+KL7v# diff --git a/src/test/resources/datasets/parquet/alltypes_plain.parquet b/src/test/resources/datasets/parquet/alltypes_plain.parquet deleted file mode 100644 index a63f5dca7c3821909748f34752966a0d7e08d47f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1851 zcmb7F&ui0g6#pje+AM2l7<*qVv_zM;YQD;X!1G_`Ye@W}TbqmweOL$NPTX=lg!8G{0y<66Rp8 z2pS|Aqlfj;PSH-&mT4zwizU$p1|0Y`VGJoyS_YbwNId;`jBg|z+DN8!b`S=|Sr$Ee51+|8u<)E>*X#arx$Xz24Q}8O`6X`}Xhr%F1Zjm_hG6In z7b&rg?-Cs*15K~?$g4Hmpi6uSk7fKqck31Rd$NO@X;dxW?*`sZ;$!02EAVEj1Dx*0 z{MLuNloZ0uLp~A&5eQYhXi-eh3&y9k4#_aQs_m^t;cL8xuhMu#`94GW^Wlpd7r_2h zbWlRre%G&Crz3oz;A@fee~~T(>&n~(=)0;8>IvyeeZ%&hb^-l_Dsj zFvuM<3gd=3Zp;SqWJI2b$YdaF$o()3pD7?YQ98!mj1HO5|D}r6be0>LFTr05hN163Gw zN`^s(6x}&&X(N%RnM7u%??nSFr{~`PZ?MG}V6i6>#vU;kXJ%l`=Er#5j4|7?$M(UP z-GIH6Am3BD#zq&s>YC+S`G?MW!>iZw=2&6OxPE8h?#;!8`C@+5-thcNe#V-dsZ?y! oaow58so4p;;FgVPyFBeqnNHc9FdWl$-SX^Jc0`|z5`8xR0vs|-V*mgE diff --git a/src/test/resources/datasets/parquet/userdata1.parquet b/src/test/resources/datasets/parquet/userdata1.parquet deleted file mode 100644 index 2ae23dac0ffe794068f26c163b7245b3509f31c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113629 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 DbfIxZ From 257cfa8d5bc57d74b74e5917ac7f97ea0eee45c0 Mon Sep 17 00:00:00 2001 From: Jakob-al28 <04jakob28@gmail.com> Date: Tue, 14 Jul 2026 17:40:15 +0200 Subject: [PATCH 3/3] [SYSTEMDS-3929] Improve array-direct parquet frame reader Decode through the column API directly into pre-allocated column backing arrays. Remove INT96 support (per-column error, other columns stay readable via projection). Expand part-file directories in the sequential reader and validate row counts in both readers. Replace the legacy Group-based test reader/writer with Spark as the reference in both directions, move the benchmarks to the performance package, and apply the Eclipse formatter. --- .../sysds/runtime/io/FrameReaderParquet.java | 407 ++++++++++++------ .../io/FrameReaderParquetParallel.java | 86 ++-- .../sysds/runtime/io/FrameWriterParquet.java | 48 ++- .../io/FrameWriterParquetParallel.java | 16 +- .../frame}/ParquetReaderBenchmark.java | 96 +++-- .../frame/ParquetWriterBenchmark.java | 206 +++++++++ .../io/parquet/FrameParquetSchemaTest.java | 124 ++---- .../io/parquet/FrameReaderParquetLegacy.java | 186 -------- .../parquet/FrameReaderWriterParquetTest.java | 64 ++- .../io/parquet/FrameWriterParquetLegacy.java | 212 --------- .../io/parquet/ParquetTestUtils.java | 185 +++++--- .../io/parquet/ParquetWriterBenchmark.java | 274 ------------ .../functions/io/parquet/ReadParquetTest.java | 274 ++++-------- .../io/parquet/WriteParquetTest.java | 182 ++++---- 14 files changed, 957 insertions(+), 1403 deletions(-) rename src/test/java/org/apache/sysds/{test/functions/io/parquet => performance/frame}/ParquetReaderBenchmark.java (67%) create mode 100644 src/test/java/org/apache/sysds/performance/frame/ParquetWriterBenchmark.java delete mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java delete mode 100755 src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java delete mode 100644 src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java index 504602ef713..79ca2cff38e 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquet.java @@ -20,34 +20,41 @@ import java.io.IOException; import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.util.concurrent.TimeUnit; +import java.util.Arrays; +import java.util.Comparator; import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; import org.apache.parquet.column.ColumnDescriptor; import org.apache.parquet.column.ColumnReader; import org.apache.parquet.column.impl.ColumnReadStoreImpl; import org.apache.parquet.column.page.PageReadStore; import org.apache.parquet.hadoop.ParquetFileReader; -import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.hadoop.metadata.FileMetaData; import org.apache.parquet.hadoop.util.HadoopInputFile; -import org.apache.parquet.io.api.Binary; import org.apache.parquet.io.api.Converter; import org.apache.parquet.io.api.GroupConverter; import org.apache.parquet.io.api.PrimitiveConverter; import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; +import org.apache.parquet.schema.Type; +import org.apache.parquet.schema.Type.Repetition; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.conf.ConfigurationManager; import org.apache.sysds.runtime.DMLRuntimeException; import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.frame.data.columns.Array; +import org.apache.sysds.runtime.frame.data.columns.ArrayFactory; import org.apache.sysds.runtime.util.HDFSTool; +import org.apache.sysds.runtime.util.UtilFunctions; /** * Single-threaded frame parquet reader. - * + * + * Decodes through parquet-mr's column API ({@link ColumnReadStoreImpl}/{@link ColumnReader}) directly into + * pre-allocated typed column arrays. The output frame is constructed from the filled arrays without copying. Columns + * whose parquet physical type does not match the requested frame value type are converted per cell instead. */ public class FrameReaderParquet extends FrameReader { @@ -62,179 +69,305 @@ public class FrameReaderParquet extends FrameReader { * @return A FrameBlock containing the data read from the Parquet file. */ @Override - public FrameBlock readFrameFromHDFS(String fname, ValueType[] schema, String[] names, long rlen, long clen) throws IOException, DMLRuntimeException { - // Prepare file access + public FrameBlock readFrameFromHDFS(String fname, ValueType[] schema, String[] names, long rlen, long clen) + throws IOException, DMLRuntimeException { Configuration conf = ConfigurationManager.getCachedJobConf(); Path path = new Path(fname); - - // Check existence and non-empty file - if (!HDFSTool.existsFileOnHDFS(path.toString())) { + if(!HDFSTool.existsFileOnHDFS(path.toString())) throw new IOException("File does not exist on HDFS: " + fname); - } - // Allocate output frame block ValueType[] lschema = createOutputSchema(schema, clen); String[] lnames = createOutputNames(names, clen); - FrameBlock ret = createOutputFrameBlock(lschema, lnames, rlen); - // Read Parquet file - readParquetFrameFromHDFS(path, conf, ret, lschema, rlen, clen); + Object[] dest = new Object[(int) clen]; + for(int c = 0; c < clen; c++) + dest[c] = ArrayFactory.allocateBacking(lschema[c], (int) rlen); - return ret; + readParquetFrameFromHDFS(path, conf, dest, lschema, lnames, rlen); + + // zero-row output stays a schema-only frame (no zero-length column arrays) + if(rlen == 0) + return new FrameBlock(lschema, lnames, 0); + + Array[] columns = new Array[(int) clen]; + for(int c = 0; c < clen; c++) + columns[c] = ArrayFactory.create(lschema[c], dest[c]); + return new FrameBlock(columns, lnames); } /** - * Reads data from a Parquet file on HDFS and fills the provided FrameBlock. - * The method retrieves the Parquet schema from the file footer, maps the required column names - * to their corresponding indices, and then uses the column API to iterate over each column. - * Data is extracted based on the column type and set into the output FrameBlock. + * Reads an entire Parquet file (or directory of part files) into the pre-allocated column backing arrays. Part + * files, if any, are read sequentially in name order. * - * @param path The HDFS path to the Parquet file. + * @param path The HDFS path to the Parquet file or directory. * @param conf The Hadoop configuration. - * @param dest The FrameBlock to populate with data. - * @param schema The expected value types for the output columns. + * @param dest The per-column backing arrays to populate. + * @param schema The value types of the output columns. + * @param names The names of the output columns. * @param rlen The expected number of rows. - * @param clen The expected number of columns. */ - protected void readParquetFrameFromHDFS(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long rlen, long clen) throws IOException { - int row = readSingleParquetFile(path, conf, dest, clen, 0); + protected void readParquetFrameFromHDFS(Path path, Configuration conf, Object[] dest, ValueType[] schema, + String[] names, long rlen) throws IOException { + FileSystem fs = IOUtilFunctions.getFileSystem(path); + Path[] files = IOUtilFunctions.getSequenceFilePaths(fs, path); + Arrays.sort(files, Comparator.comparing(Path::getName)); - // Check frame dimensions - if (row != rlen) { - throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + row); - } + long off = 0; + for(Path file : files) + off += readSingleParquetFile(file, conf, dest, schema, names, rlen, (int) off); + if(off != rlen) + throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + off); } - // Constants for decoding legacy INT96 timestamps - private static final int JULIAN_EPOCH_OFFSET_DAYS = 2_440_588; - private static final long MILLIS_IN_DAY = TimeUnit.DAYS.toMillis(1); - private static final long NANOS_PER_MILLISECOND = TimeUnit.MILLISECONDS.toNanos(1); - /** - * Reads a single Parquet file into the destination FrameBlock using the column API. - * Iterates row groups; within each row group reads each requested column and writes the - * value into the FrameBlock. + * Decodes a single Parquet file into the column backing arrays at the given row offset, one row group at a time and + * column-at-a-time within each row group. Thread-safe for distinct row ranges, so the parallel reader assigns each + * file its own offset and shares the output arrays. * * @param path The HDFS path to the Parquet file. * @param conf The Hadoop configuration. - * @param dest The FrameBlock to populate. - * @param clen The number of columns. - * @param rowOffset The starting row offset in the destination FrameBlock. + * @param dest The per-column backing arrays to populate. + * @param schema The value types of the output columns. + * @param names The names of the output columns. + * @param rlen The total number of output rows (exclusive upper bound of this file's rows). + * @param rowOffset The row offset of this file's first row. * @return The number of rows read. */ - protected int readSingleParquetFile(Path path, Configuration conf, FrameBlock dest, - long clen, long rowOffset) throws IOException - { - String[] columnNames = dest.getColumnNames(); - - try (ParquetFileReader reader = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) { - ParquetMetadata metadata = reader.getFooter(); - MessageType parquetSchema = metadata.getFileMetaData().getSchema(); - String createdBy = metadata.getFileMetaData().getCreatedBy(); - - // Map each requested frame column (by name) to its Parquet column descriptor. - ColumnDescriptor[] descriptors = new ColumnDescriptor[(int) clen]; - for (int col = 0; col < clen; col++) { - ColumnDescriptor desc = parquetSchema.getColumnDescription(new String[]{ columnNames[col] }); - // Nested columns cannot be represented by a flat FrameBlock column - if (desc.getMaxRepetitionLevel() > 0) - throw new IOException("Nested Parquet columns are not supported: " + columnNames[col]); - descriptors[col] = desc; - } + protected int readSingleParquetFile(Path path, Configuration conf, Object[] dest, ValueType[] schema, + String[] names, long rlen, int rowOffset) throws IOException { + final int ncol = schema.length; + try(ParquetFileReader reader = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) { + FileMetaData meta = reader.getFooter().getFileMetaData(); + MessageType parquetSchema = meta.getSchema(); + String createdBy = meta.getCreatedBy(); - // no-op converter tree, only used by ColumnReadStoreImpl to resolve a converter per column - GroupConverter rootConverter = newNoOpRootConverter(parquetSchema); + // map each requested frame column (by name) to its parquet column descriptor + final ColumnDescriptor[] descs = new ColumnDescriptor[ncol]; + for(int c = 0; c < ncol; c++) + descs[c] = validateDecodable(parquetSchema, names[c]); - int row = (int) rowOffset; + GroupConverter root = dummyConverter(parquetSchema.getFieldCount()); + int off = rowOffset; PageReadStore pages; - - while (true) { - pages = reader.readNextRowGroup(); - - if (pages == null) - break; - - int rowsInGroup = (int) pages.getRowCount(); - ColumnReadStoreImpl colStore = new ColumnReadStoreImpl(pages, rootConverter, parquetSchema, createdBy); - - for (int col = 0; col < clen; col++) { - ColumnDescriptor desc = descriptors[col]; - ColumnReader creader = colStore.getColumnReader(desc); - int maxDef = desc.getMaxDefinitionLevel(); - PrimitiveType.PrimitiveTypeName ptype = desc.getPrimitiveType().getPrimitiveTypeName(); - readColumn(dest, creader, col, row, rowsInGroup, maxDef, ptype); + while((pages = reader.readNextRowGroup()) != null) { + int nrow = (int) pages.getRowCount(); + if(off + nrow > rlen) + throw new IOException( + "Mismatch in row count: expected " + rlen + ", but got at least " + (off + nrow)); + ColumnReadStoreImpl store = new ColumnReadStoreImpl(pages, root, parquetSchema, createdBy); + for(int c = 0; c < ncol; c++) { + ColumnReader creader = store.getColumnReader(descs[c]); + int maxDef = descs[c].getMaxDefinitionLevel(); + PrimitiveTypeName ptype = descs[c].getPrimitiveType().getPrimitiveTypeName(); + if(directDecodable(ptype, schema[c])) + decodeColumnInto(creader, maxDef, nrow, ptype, dest[c], off); + else + decodeColumnConvert(creader, maxDef, nrow, ptype, schema[c], dest[c], off); } - row += rowsInGroup; + off += nrow; } - return row - (int) rowOffset; + return off - rowOffset; + } + } + + /** + * Resolve the descriptor of one parquet column and verify it is decodable: a non-nested primitive of a physical + * type the reader supports (INT96 timestamps and nested/repeated groups are not). + */ + private static ColumnDescriptor validateDecodable(MessageType parquetSchema, String name) throws IOException { + if(!parquetSchema.containsField(name)) + throw new IOException("Column not found in Parquet schema: " + name); + Type t = parquetSchema.getType(name); + if(!t.isPrimitive() || t.isRepetition(Repetition.REPEATED)) + throw new IOException("Nested Parquet columns are not supported: " + name); + PrimitiveTypeName ptype = t.asPrimitiveType().getPrimitiveTypeName(); + switch(ptype) { + case INT32: + case INT64: + case FLOAT: + case DOUBLE: + case BOOLEAN: + case BINARY: + return parquetSchema.getColumnDescription(new String[] {name}); + default: + throw new IOException("Unsupported Parquet type " + ptype + " for column: " + name + + (ptype == PrimitiveTypeName.INT96 ? " (deprecated INT96 timestamps; re-encode as INT64)" : "")); } } /** - * Reads one column of a row group, writing each value (or null) into the destination FrameBlock. + * Whether the parquet physical type is the one the frame value type's backing array stores, i.e. the column can be + * decoded through the typed direct path without per-value conversion. */ - private void readColumn(FrameBlock dest, ColumnReader creader, int col, int rowStart, - int rowsInGroup, int maxDef, PrimitiveType.PrimitiveTypeName ptype) throws IOException - { - for (int i = 0; i < rowsInGroup; i++) { - int row = rowStart + i; - if (creader.getCurrentDefinitionLevel() == maxDef) { - switch (ptype) { - case INT32: - dest.set(row, col, creader.getInteger()); - break; - case INT64: - dest.set(row, col, creader.getLong()); - break; - case FLOAT: - dest.set(row, col, creader.getFloat()); - break; - case DOUBLE: - dest.set(row, col, creader.getDouble()); - break; - case BOOLEAN: - dest.set(row, col, creader.getBoolean()); - break; - case INT96: { - // Legacy INT96 timestamp, narrowed to epoch millis. - // See https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#timestamp - Binary binary = creader.getBinary(); - ByteBuffer buf = ByteBuffer.wrap(binary.getBytes()).order(ByteOrder.LITTLE_ENDIAN); - long nanosOfDay = buf.getLong(); - int julianDay = buf.getInt(); - long millis = (julianDay - JULIAN_EPOCH_OFFSET_DAYS) * MILLIS_IN_DAY - + nanosOfDay / NANOS_PER_MILLISECOND; - dest.set(row, col, millis); - break; - } - case BINARY: - dest.set(row, col, creader.getBinary().toStringUsingUTF8()); - break; - default: - throw new IOException("Unsupported data type: " + ptype); + private static boolean directDecodable(PrimitiveTypeName ptype, ValueType vt) { + switch(ptype) { + case INT32: + return vt == ValueType.INT32; + case INT64: + return vt == ValueType.INT64; + case FLOAT: + return vt == ValueType.FP32; + case DOUBLE: + return vt == ValueType.FP64; + case BOOLEAN: + return vt == ValueType.BOOLEAN; + default: + return vt == ValueType.STRING; // BINARY + } + } + + /** + * Decode one parquet column of the current row group into a pre-allocated typed array at the given offset. Null + * cells (definition level below max) keep the array default (0 for numerics, null for strings). + */ + private static void decodeColumnInto(ColumnReader creader, int maxDef, int nrow, PrimitiveTypeName ptype, + Object dest, int off) { + final int end = off + nrow; + switch(ptype) { + case INT32: { + int[] a = (int[]) dest; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + a[r] = creader.getInteger(); + creader.consume(); + } + break; + } + case INT64: { + long[] a = (long[]) dest; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + a[r] = creader.getLong(); + creader.consume(); + } + break; + } + case FLOAT: { + float[] a = (float[]) dest; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + a[r] = creader.getFloat(); + creader.consume(); + } + break; + } + case DOUBLE: { + double[] a = (double[]) dest; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + a[r] = creader.getDouble(); + creader.consume(); + } + break; + } + case BOOLEAN: { + boolean[] a = (boolean[]) dest; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + a[r] = creader.getBoolean(); + creader.consume(); } + break; } - else { - dest.set(row, col, null); + default: { // BINARY + String[] a = (String[]) dest; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + a[r] = creader.getBinary().toStringUsingUTF8(); + creader.consume(); + } + break; } + } + } + + /** + * Decode one parquet column whose physical type does not match the frame value type, converting each value per cell + * (same semantics as {@link FrameBlock#set(int, int, Object)}, e.g. a DOUBLE file column read into a STRING frame + * column). + */ + private static void decodeColumnConvert(ColumnReader creader, int maxDef, int nrow, PrimitiveTypeName ptype, + ValueType vt, Object dest, int off) { + final int end = off + nrow; + for(int r = off; r < end; r++) { + if(creader.getCurrentDefinitionLevel() == maxDef) + setConverted(dest, vt, r, readValue(creader, ptype)); creader.consume(); } } + private static Object readValue(ColumnReader creader, PrimitiveTypeName ptype) { + switch(ptype) { + case INT32: + return creader.getInteger(); + case INT64: + return creader.getLong(); + case FLOAT: + return creader.getFloat(); + case DOUBLE: + return creader.getDouble(); + case BOOLEAN: + return creader.getBoolean(); + default: + return creader.getBinary().toStringUsingUTF8(); // BINARY + } + } + /** - * Builds a no-op converter tree matching the (flat) Parquet schema. The converter - * callbacks are never invoked because values are read through the typed creader.getX accessors in readColumn(). - * The tree merely serves to satisfy the ColumnReadStoreImpl constructor + * Store one converted value into the backing array of the given value type. */ - private static GroupConverter newNoOpRootConverter(MessageType schema) { - final int n = schema.getFieldCount(); - final PrimitiveConverter[] leaves = new PrimitiveConverter[n]; - for (int i = 0; i < n; i++) - leaves[i] = new PrimitiveConverter() {}; + private static void setConverted(Object dest, ValueType vt, int r, Object val) { + Object converted = UtilFunctions.objectToObject(vt, val); + if(converted == null) + return; + switch(vt) { + case FP64: + ((double[]) dest)[r] = (Double) converted; + break; + case FP32: + ((float[]) dest)[r] = (Float) converted; + break; + case INT64: + case HASH64: + ((long[]) dest)[r] = (Long) converted; + break; + case UINT4: + case UINT8: + case INT32: + case HASH32: + ((int[]) dest)[r] = (Integer) converted; + break; + case BOOLEAN: + ((boolean[]) dest)[r] = (Boolean) converted; + break; + case CHARACTER: + ((char[]) dest)[r] = (Character) converted; + break; + default: + ((String[]) dest)[r] = (String) converted; + break; + } + } + + /** No-op converter tree; the column API requires one, but values are pulled via the typed getters. */ + private static GroupConverter dummyConverter(int nFields) { + final PrimitiveConverter[] leaves = new PrimitiveConverter[nFields]; + for(int i = 0; i < nFields; i++) + leaves[i] = new PrimitiveConverter() { + }; return new GroupConverter() { - @Override public Converter getConverter(int fieldIndex) { return leaves[fieldIndex]; } - @Override public void start() {} - @Override public void end() {} + @Override + public Converter getConverter(int fieldIndex) { + return leaves[fieldIndex]; + } + + @Override + public void start() { + } + + @Override + public void end() { + } }; } @@ -244,4 +377,4 @@ public FrameBlock readFrameFromInputStream(InputStream is, ValueType[] schema, S throws IOException, DMLRuntimeException { throw new UnsupportedOperationException("Unimplemented method 'readFrameFromInputStream'"); } -} \ No newline at end of file +} diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java index da25157512a..a28a366a317 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameReaderParquetParallel.java @@ -31,97 +31,77 @@ import org.apache.hadoop.fs.Path; import org.apache.parquet.hadoop.ParquetFileReader; import org.apache.parquet.hadoop.metadata.BlockMetaData; -import org.apache.parquet.hadoop.metadata.ParquetMetadata; import org.apache.parquet.hadoop.util.HadoopInputFile; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.hops.OptimizerUtils; -import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.frame.data.FrameBlock; import org.apache.sysds.runtime.util.CommonThreadPool; /** - * Multi-threaded frame parquet reader. - * + * Multi-threaded frame parquet reader: reads one file per task, each decoding into its own row range of the shared + * column backing arrays. Per-file row offsets are derived from the row counts in the file footers. */ public class FrameReaderParquetParallel extends FrameReaderParquet { - - /** - * Reads a Parquet frame in parallel and populates the provided FrameBlock with the data. - * The method retrieves all file paths from the sequence files at that location, it then determines - * the number of threads to use based on the available files and a configured parallelism setting. - * A thread pool is created to run a reading task for each file concurrently. - * - * @param path The HDFS path to the Parquet file or the directory containing sequence files. - * @param conf The Hadoop configuration. - * @param dest The FrameBlock to be updated with the data read from the files. - * @param schema The expected value types for the frame columns. - * @param rlen The expected number of rows. - * @param clen The expected number of columns. - */ + @Override - protected void readParquetFrameFromHDFS(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long rlen, long clen) throws IOException, DMLRuntimeException { + protected void readParquetFrameFromHDFS(Path path, Configuration conf, Object[] dest, ValueType[] schema, + String[] names, long rlen) throws IOException { FileSystem fs = IOUtilFunctions.getFileSystem(path); Path[] files = IOUtilFunctions.getSequenceFilePaths(fs, path); Arrays.sort(files, Comparator.comparing(Path::getName)); int numThreads = Math.min(OptimizerUtils.getParallelBinaryReadParallelism(), files.length); - + long[] offsets = new long[files.length]; long cumulative = 0; - - for (int i = 0; i < files.length; i++) { - long fileRows = 0; - - try (ParquetFileReader reader = - ParquetFileReader.open(HadoopInputFile.fromPath(files[i], conf))) { - - ParquetMetadata meta = reader.getFooter(); - - for (BlockMetaData block : meta.getBlocks()) { - fileRows += block.getRowCount(); - } - } - + for(int i = 0; i < files.length; i++) { offsets[i] = cumulative; - cumulative += fileRows; + try(ParquetFileReader reader = ParquetFileReader.open(HadoopInputFile.fromPath(files[i], conf))) { + for(BlockMetaData block : reader.getFooter().getBlocks()) + cumulative += block.getRowCount(); + } } + if(cumulative != rlen) + throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + cumulative); - // Create and execute read tasks ExecutorService pool = CommonThreadPool.get(numThreads); try { List tasks = new ArrayList<>(); - for (int i = 0; i < files.length; i++) { - tasks.add(new ReadFileTask(files[i], conf, dest, clen, offsets[i])); - } + for(int i = 0; i < files.length; i++) + tasks.add(new ReadFileTask(files[i], conf, dest, schema, names, rlen, (int) offsets[i])); - for (Future task : pool.invokeAll(tasks)) { + for(Future task : pool.invokeAll(tasks)) task.get(); - } - } catch (Exception e) { + } + catch(Exception e) { throw new IOException("Failed parallel read of Parquet frame.", e); - } finally { + } + finally { pool.shutdown(); } } private class ReadFileTask implements Callable { - private Path path; - private Configuration conf; - private FrameBlock dest; - private long clen; - private long rowOffset; + private final Path path; + private final Configuration conf; + private final Object[] dest; + private final ValueType[] schema; + private final String[] names; + private final long rlen; + private final int rowOffset; - public ReadFileTask(Path path, Configuration conf, FrameBlock dest, long clen, long rowOffset) { + public ReadFileTask(Path path, Configuration conf, Object[] dest, ValueType[] schema, String[] names, long rlen, + int rowOffset) { this.path = path; this.conf = conf; this.dest = dest; - this.clen = clen; + this.schema = schema; + this.names = names; + this.rlen = rlen; this.rowOffset = rowOffset; } - // When executed, a ParquetReader for the assigned file opens and iterates over each row processing every column. @Override public Object call() throws Exception { - FrameReaderParquetParallel.super.readSingleParquetFile(path, conf, dest, clen, rowOffset); + readSingleParquetFile(path, conf, dest, schema, names, rlen, rowOffset); return null; } } diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java index 35e457e334d..775bfde0ce7 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquet.java @@ -48,7 +48,9 @@ */ public class FrameWriterParquet extends FrameWriter { - public enum DictEncoding { ALL_ON, ALL_OFF, STRING_ONLY } + public enum DictEncoding { + ALL_ON, ALL_OFF, STRING_ONLY + } private final CompressionCodecName codec; private final DictEncoding dictEncoding; @@ -95,9 +97,9 @@ public final void writeFrameToHDFS(FrameBlock src, String fname, long rlen, long } /** - * Writes the FrameBlock data to a Parquet file using a ParquetWriter. - * The method generates a Parquet schema based on the metadata of the FrameBlock, initializes a ParquetWriter with specified configurations, - * iterates over each row and column, writing directly to the RecordConsumer, using type-specific conversions. + * Writes the FrameBlock data to a Parquet file using a ParquetWriter. The method generates a Parquet schema based + * on the metadata of the FrameBlock, initializes a ParquetWriter with specified configurations, iterates over each + * row and column, writing directly to the RecordConsumer, using type-specific conversions. * * @param path The HDFS path where the Parquet file will be written. * @param conf The Hadoop configuration. @@ -114,21 +116,23 @@ protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock String[] columnNames = src.getColumnNames(); ValueType[] columnTypes = src.getSchema(); - FrameParquetWriterBuilder writerBuilder = new FrameParquetWriterBuilder(path, schema, src) - .withConf(conf) - .withCompressionCodec(CompressionCodecName.fromConf(conf.get(ParquetOutputFormat.COMPRESSION, codec.name()))) - .withRowGroupSize(conf.getLong(ParquetOutputFormat.BLOCK_SIZE, rowGroupSize)) - .withPageSize(conf.getInt(ParquetOutputFormat.PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE)) - .withDictionaryPageSize(conf.getInt(ParquetOutputFormat.DICTIONARY_PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE)) - .withDictionaryEncoding(conf.getBoolean(ParquetOutputFormat.ENABLE_DICTIONARY, dictEncoding == DictEncoding.ALL_ON)); + FrameParquetWriterBuilder writerBuilder = new FrameParquetWriterBuilder(path, schema, src).withConf(conf) + .withCompressionCodec( + CompressionCodecName.fromConf(conf.get(ParquetOutputFormat.COMPRESSION, codec.name()))) + .withRowGroupSize(conf.getLong(ParquetOutputFormat.BLOCK_SIZE, rowGroupSize)) + .withPageSize(conf.getInt(ParquetOutputFormat.PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE)) + .withDictionaryPageSize( + conf.getInt(ParquetOutputFormat.DICTIONARY_PAGE_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE)) + .withDictionaryEncoding( + conf.getBoolean(ParquetOutputFormat.ENABLE_DICTIONARY, dictEncoding == DictEncoding.ALL_ON)); - if (dictEncoding == DictEncoding.STRING_ONLY) - for (int j = 0; j < src.getNumColumns(); j++) - if (columnTypes[j] == ValueType.STRING) + if(dictEncoding == DictEncoding.STRING_ONLY) + for(int j = 0; j < src.getNumColumns(); j++) + if(columnTypes[j] == ValueType.STRING) writerBuilder = writerBuilder.withDictionaryEncoding(columnNames[j], true); - try (ParquetWriter writer = writerBuilder.build()) { - for (int i = 0; i < src.getNumRows(); i++) + try(ParquetWriter writer = writerBuilder.build()) { + for(int i = 0; i < src.getNumRows(); i++) writer.write(i); } @@ -150,8 +154,7 @@ protected MessageType createParquetSchema(FrameBlock src) { for (int i = 0; i < src.getNumColumns(); i++) { switch (columnTypes[i]) { case STRING: - builder.optional(PrimitiveTypeName.BINARY) - .as(LogicalTypeAnnotation.stringType()) + builder.optional(PrimitiveTypeName.BINARY).as(LogicalTypeAnnotation.stringType()) .named(columnNames[i]); break; case INT32: @@ -177,8 +180,7 @@ protected MessageType createParquetSchema(FrameBlock src) { } /** - * WriteSupport implementation that writes rows from a FrameBlock directly to the - * Parquet RecordConsumer. + * WriteSupport implementation that writes rows from a FrameBlock directly to the Parquet RecordConsumer. */ private static class FrameWriteSupport extends WriteSupport { private final MessageType schema; @@ -211,11 +213,11 @@ public void prepareForWrite(RecordConsumer consumer) { @Override public void write(Integer rowIndex) { recordConsumer.startMessage(); - for (int j = 0; j < numCols; j++) { + for(int j = 0; j < numCols; j++) { Object value = src.get(rowIndex, j); - if (value != null) { + if(value != null) { recordConsumer.startField(colNames[j], j); - switch (colTypes[j]) { + switch(colTypes[j]) { case STRING: recordConsumer.addBinary(Binary.fromString(value.toString())); break; diff --git a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java index 441ec2db550..3efbbbefef0 100644 --- a/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java +++ b/src/main/java/org/apache/sysds/runtime/io/FrameWriterParquetParallel.java @@ -41,11 +41,10 @@ public class FrameWriterParquetParallel extends FrameWriterParquet { /** - * Writes the FrameBlock data to HDFS in parallel. - * The method estimates the number of output partitions by comparing the estimated output size of the FrameBlock with the - * HDFS block size. It then determines the number of threads to use based on the parallelism configuration and the - * number of partitions. In case of parallelism, it divides the FrameBlock into chunks and a thread pool is created to - * execute a write task for each partition concurrently. + * Writes the FrameBlock data to HDFS in parallel. The method estimates the number of output partitions by comparing + * the estimated output size of the FrameBlock with the HDFS block size. It then determines the number of threads to + * use based on the parallelism configuration and the number of partitions. In case of parallelism, it divides the + * FrameBlock into chunks and a thread pool is created to execute a write task for each partition concurrently. * * @param path The HDFS path where the Parquet files will be written. * @param conf The Hadoop configuration. @@ -56,13 +55,14 @@ protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock throws IOException, DMLRuntimeException { // Estimate output partitions from output size in bytes - int numPartFiles = Math.max((int) (OptimizerUtils.estimateSizeExactFrame(src.getNumRows(), src.getNumColumns()) - / InfrastructureAnalyzer.getHDFSBlockSize()), 1); + int numPartFiles = Math + .max((int) (OptimizerUtils.estimateSizeExactFrame(src.getNumRows(), src.getNumColumns()) / + InfrastructureAnalyzer.getHDFSBlockSize()), 1); // Determine parallelism int numThreads = Math.min(OptimizerUtils.getParallelBinaryWriteParallelism(), numPartFiles); - if (!_forcedParallel && numThreads <= 1) { + if(!_forcedParallel && numThreads <= 1) { super.writeParquetFrameToHDFS(path, conf, src); return; } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java b/src/test/java/org/apache/sysds/performance/frame/ParquetReaderBenchmark.java similarity index 67% rename from src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java rename to src/test/java/org/apache/sysds/performance/frame/ParquetReaderBenchmark.java index 592e610185f..b2f4c77bcbd 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetReaderBenchmark.java +++ b/src/test/java/org/apache/sysds/performance/frame/ParquetReaderBenchmark.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.sysds.test.functions.io.parquet; +package org.apache.sysds.performance.frame; import java.io.BufferedReader; import java.io.File; @@ -30,48 +30,44 @@ import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.runtime.frame.data.FrameBlock; import org.apache.sysds.runtime.io.FrameReaderParquet; +import org.apache.sysds.runtime.io.FrameReaderParquetParallel; import org.apache.sysds.runtime.io.FrameWriterParquet; import org.junit.After; import org.junit.Assume; /** - * Parquet reader benchmark comparing the column-API reader against - * the Group-based reader. + * Parquet reader benchmark comparing the sequential and parallel column-API readers on the TPC-H lineitem dataset. * - * Results report median and min/max across runs and are appended to - * temp/benchmark_results.csv for plotting. + * Results report median and min/max across runs and are appended to temp/benchmark_results.csv for plotting. * - * If a dataset is not present the corresponding test is skipped with instructions. + * The benchmark methods are disabled by default; uncomment the Test annotations to run manually. If the dataset is not + * present the benchmark is skipped with instructions. */ public class ParquetReaderBenchmark { - private static final String TPCH_FILE = "temp/lineitem.tbl"; + private static final String TPCH_FILE = "temp/lineitem.tbl"; private static final String RESULTS_CSV = "temp/benchmark_results.csv"; - private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_read_bench.parquet"; - private static final int RUNS = 7; + private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_read_bench.parquet"; + private static final int RUNS = 7; // override on larger machine with -DmaxRows=... (e.g. -DmaxRows=20000000) to test larger row groups. - private static final int MAX_ROWS = Integer.getInteger("maxRows", 2_000_000); - - // TPC-H lineitem schema - private static final ValueType[] LINEITEM_SCHEMA = { - ValueType.INT64, ValueType.INT64, ValueType.INT64, ValueType.INT32, - ValueType.FP64, ValueType.FP64, ValueType.FP64, ValueType.FP64, - ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING, - ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING - }; - private static final String[] LINEITEM_NAMES = { - "orderkey", "partkey", "suppkey", "linenumber", - "quantity", "extendedprice", "discount", "tax", - "returnflag", "linestatus", "shipdate", "commitdate", - "receiptdate", "shipinstruct", "shipmode", "comment" - }; + private static final int MAX_ROWS = Integer.getInteger("maxRows", 2_000_000); + + // TPC-H lineitem schema + private static final ValueType[] LINEITEM_SCHEMA = {ValueType.INT64, ValueType.INT64, ValueType.INT64, + ValueType.INT32, ValueType.FP64, ValueType.FP64, ValueType.FP64, ValueType.FP64, ValueType.STRING, + ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING, ValueType.STRING, + ValueType.STRING}; + private static final String[] LINEITEM_NAMES = {"orderkey", "partkey", "suppkey", "linenumber", "quantity", + "extendedprice", "discount", "tax", "returnflag", "linestatus", "shipdate", "commitdate", "receiptdate", + "shipinstruct", "shipmode", "comment"}; private PrintWriter csv; @After public void cleanup() { new File(TEMP_FILE).delete(); - if (csv != null) csv.close(); + if(csv != null) + csv.close(); } // @Test @@ -81,15 +77,19 @@ public void benchmarkReadTpch() throws Exception { } private static final class ReadSpec { - final ValueType[] schema; final String[] names; final int rows; final int cols; + final ValueType[] schema; + final String[] names; + final int rows; + final int cols; + ReadSpec(ValueType[] schema, String[] names, int rows, int cols) { - this.schema = schema; this.names = names; this.rows = rows; this.cols = cols; + this.schema = schema; + this.names = names; + this.rows = rows; + this.cols = cols; } } - /** - * Writes a frame to Parquet, then times reading it back with both sequential and parallel readers. - */ private ReadSpec writeTempParquet(FrameBlock data) throws Exception { int rows = data.getNumRows(), cols = data.getNumColumns(); ReadSpec spec = new ReadSpec(data.getSchema(), data.getColumnNames(), rows, cols); @@ -99,7 +99,7 @@ private ReadSpec writeTempParquet(FrameBlock data) throws Exception { } /** - * Writes a frame to a Parquet, then benchmarks sequential and parallel reads + * Writes a frame to Parquet once, then times reading it back with both readers. */ private void runReadBenchmark(String category, ReadSpec spec) throws Exception { final ValueType[] schema = spec.schema; @@ -112,24 +112,27 @@ private void runReadBenchmark(String category, ReadSpec spec) throws Exception { probe = null; openCsv(); - System.out.println("\n=== Parquet Read Benchmark [" + category + "] (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.println( + "\n=== Parquet Read Benchmark [" + category + "] (" + rows + " rows, median of " + RUNS + " runs) ===\n"); System.out.printf("%-24s %12s %15s %s%n", "Reader", "Median (ms)", "Rows/sec", "[runs]"); System.out.println("-".repeat(72)); - timeRead(category, "Legacy (Group)", () -> - new FrameReaderParquetLegacy().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols), rows); - timeRead(category, "Column API", () -> - new FrameReaderParquet().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols), rows); + timeRead(category, "Sequential", + () -> new FrameReaderParquet().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols), rows); + timeRead(category, "Parallel", + () -> new FrameReaderParquetParallel().readFrameFromHDFS(TEMP_FILE, schema, names, rows, cols), rows); System.out.println(); } - private interface ReadAction { FrameBlock run() throws Exception; } + private interface ReadAction { + FrameBlock run() throws Exception; + } private void timeRead(String category, String label, ReadAction action, int rows) throws Exception { action.run(); // warmup long[] times = new long[RUNS]; - for (int run = 0; run < RUNS; run++) { + for(int run = 0; run < RUNS; run++) { long start = System.currentTimeMillis(); action.run(); times[run] = System.currentTimeMillis() - start; @@ -137,7 +140,8 @@ private void timeRead(String category, String label, ReadAction action, int rows long med = median(times); long min = Arrays.stream(times).min().orElse(med); long max = Arrays.stream(times).max().orElse(med); - System.out.printf("%-24s %12d %15.0f %14s %s%n", label, med, rows * 1000.0 / med, meanStd(times), Arrays.toString(times)); + System.out.printf("%-24s %12d %15.0f %14s %s%n", label, med, rows * 1000.0 / med, meanStd(times), + Arrays.toString(times)); // columns: benchmark,label,time_ms(median),rows_per_sec,min_ms,max_ms csv.printf("%s,%s,%d,%.0f,%d,%d%n", category, label, med, rows * 1000.0 / med, min, max); } @@ -158,24 +162,26 @@ private void openCsv() throws Exception { new File("temp").mkdirs(); boolean exists = new File(RESULTS_CSV).exists(); csv = new PrintWriter(new FileWriter(RESULTS_CSV, true)); - if (!exists) + if(!exists) csv.println("benchmark,label,time_ms,rows_per_sec,size_mb,compression_ratio"); csv.flush(); } private FrameBlock loadLineitemOrSkip() throws Exception { File f = new File(TPCH_FILE); - if (!f.exists()) { + if(!f.exists()) { System.out.println("=== TPC-H read benchmark skipped, dataset not found at " + TPCH_FILE + " ==="); Assume.assumeTrue("TPC-H dataset not found at " + TPCH_FILE, false); } System.out.print("Loading " + f.getPath() + " ... "); List rows = new ArrayList<>(); - try (BufferedReader br = new BufferedReader(new FileReader(f))) { + try(BufferedReader br = new BufferedReader(new FileReader(f))) { String line; - while ((line = br.readLine()) != null && rows.size() < MAX_ROWS) { - if (line.isEmpty()) continue; - if (line.endsWith("|")) line = line.substring(0, line.length() - 1); + while((line = br.readLine()) != null && rows.size() < MAX_ROWS) { + if(line.isEmpty()) + continue; + if(line.endsWith("|")) + line = line.substring(0, line.length() - 1); rows.add(line.split("\\|", -1)); } } diff --git a/src/test/java/org/apache/sysds/performance/frame/ParquetWriterBenchmark.java b/src/test/java/org/apache/sysds/performance/frame/ParquetWriterBenchmark.java new file mode 100644 index 00000000000..aec1f05f70d --- /dev/null +++ b/src/test/java/org/apache/sysds/performance/frame/ParquetWriterBenchmark.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sysds.performance.frame; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.parquet.hadoop.metadata.CompressionCodecName; +import org.apache.sysds.common.Types.ValueType; +import org.apache.sysds.runtime.frame.data.FrameBlock; +import org.apache.sysds.runtime.io.FrameWriterParquet; +import org.apache.sysds.runtime.io.FrameWriterParquet.DictEncoding; +import org.junit.After; +import org.junit.Assume; + +/** + * Parquet writer benchmark using the TPC-H lineitem dataset. Writes results to temp/benchmark_results.csv for plotting. + * + * The benchmark methods are disabled by default; uncomment to run manually. If the dataset is not present inside the + * temp dir the corresponding benchmark is skipped with instructions. + * + * The default maxRows=2_000_000, to benchmark row-group size properly, run on a machine with enough RAM for a larger + * frame: # 1. Generate a larger TPC-H lineitem into temp/lineitem.tbl # 2. Raise the maxRows cap, then run the + * benchmark: mvn test -Dtest=ParquetWriterBenchmark#benchmarkRowGroupSizes \ -DmaxRows=60000000 -DargLine="-Xms24g + * -Xmx24g" -DfailIfNoTests=false + */ +public class ParquetWriterBenchmark { + + private static final String TPCH_FILE = "temp/lineitem.tbl"; + private static final String RESULTS_CSV = "temp/benchmark_results.csv"; + private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_tpch_bench.parquet"; + private static final int RUNS = 3; + private static final int MAX_ROWS = Integer.getInteger("maxRows", 2_000_000); + private static final long[] ROW_GROUP_SIZES = {1024 * 1024, // 1 MB + 8L * 1024 * 1024, // 8 MB + 16L * 1024 * 1024, // 16 MB + 32L * 1024 * 1024, // 32 MB + 64L * 1024 * 1024, // 64 MB + 128L * 1024 * 1024, // 128 MB (Parquet default) + 256L * 1024 * 1024, // 256 MB + 512L * 1024 * 1024 // 512 MB + }; + + // TPC-H lineitem schema + private static final ValueType[] LINEITEM_SCHEMA = {ValueType.INT64, // orderkey + ValueType.INT64, // partkey + ValueType.INT64, // suppkey + ValueType.INT32, // linenumber + ValueType.FP64, // quantity + ValueType.FP64, // extendedprice + ValueType.FP64, // discount + ValueType.FP64, // tax + ValueType.STRING, // returnflag (3 unique values) + ValueType.STRING, // linestatus (2 unique values) + ValueType.STRING, // shipdate + ValueType.STRING, // commitdate + ValueType.STRING, // receiptdate + ValueType.STRING, // shipinstruct (4 unique values) + ValueType.STRING, // shipmode (7 unique values) + ValueType.STRING // comment + }; + + private static final String[] LINEITEM_NAMES = {"orderkey", "partkey", "suppkey", "linenumber", "quantity", + "extendedprice", "discount", "tax", "returnflag", "linestatus", "shipdate", "commitdate", "receiptdate", + "shipinstruct", "shipmode", "comment"}; + + private PrintWriter csv; + + @After + public void cleanup() { + new File(TEMP_FILE).delete(); + if(csv != null) + csv.close(); + } + + // @Test + public void benchmarkDictionaryEncoding() throws Exception { + FrameBlock data = loadOrSkip(); + int rows = data.getNumRows(); + + openCsv(); + System.out.println( + "\n=== TPC-H Dictionary Encoding Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.printf("%-20s %12s %15s%n", "Strategy", "Time (ms)", "Rows/sec"); + System.out.println("-".repeat(52)); + + time("encoding", "ALL_ON", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_ON), data, + rows); + time("encoding", "ALL_OFF", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_OFF), + data, rows); + time("encoding", "STRING_ONLY", + new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.STRING_ONLY), data, rows); + System.out.println(); + } + + // @Test + public void benchmarkRowGroupSizes() throws Exception { + FrameBlock data = loadOrSkip(); + int rows = data.getNumRows(); + + openCsv(); + System.out + .println("\n=== TPC-H Row Group Size Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); + System.out.printf("%-20s %12s %15s%n", "Row Group Size", "Time (ms)", "Rows/sec"); + System.out.println("-".repeat(52)); + + for(long rowGroupSize : ROW_GROUP_SIZES) { + String label = (rowGroupSize / (1024 * 1024)) + "MB"; + time("row_group_sizes", label, + new FrameWriterParquet(CompressionCodecName.ZSTD, DictEncoding.ALL_ON, rowGroupSize), data, rows); + } + System.out.println(); + } + + private FrameBlock loadOrSkip() throws Exception { + File f = new File(TPCH_FILE); + if(!f.exists()) { + System.out.println(); + System.out.println("==================================================="); + System.out.println("TPC-H benchmark skipped, dataset not found"); + System.out.println("To reproduce:"); + System.out.println(" 1. Install DuckDB: https://duckdb.org"); + System.out.println(" 2. Open shell: duckdb"); + System.out.println(" 3. Run in DuckDB: INSTALL tpch;"); + System.out.println(" LOAD tpch;"); + System.out.println(" CALL dbgen(sf=1);"); + System.out.println(" COPY lineitem TO '/temp/lineitem.tbl'"); + System.out.println(" (DELIMITER '|', HEADER false);"); + System.out.println("==================================================="); + Assume.assumeTrue("TPC-H dataset not found at " + TPCH_FILE, false); + } + return loadLineitem(f); + } + + private void openCsv() throws Exception { + new File("temp").mkdirs(); + boolean exists = new File(RESULTS_CSV).exists(); + csv = new PrintWriter(new FileWriter(RESULTS_CSV, true)); + if(!exists) + csv.println("benchmark,label,time_ms,rows_per_sec,size_mb,compression_ratio"); + csv.flush(); + } + + private void time(String category, String label, FrameWriterParquet writer, FrameBlock data, int rows) + throws Exception { + new File(TEMP_FILE).delete(); + writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); // warmup + + long[] times = new long[RUNS]; + for(int run = 0; run < RUNS; run++) { + new File(TEMP_FILE).delete(); + long start = System.currentTimeMillis(); + writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); + times[run] = System.currentTimeMillis() - start; + } + long med = median(times); + System.out.printf("%-20s %12d %15.0f%n", label, med, rows * 1000.0 / med); + csv.printf("%s,%s,%d,%.0f,,%n", category, label, med, rows * 1000.0 / med); + } + + private static long median(long[] times) { + long[] sorted = times.clone(); + Arrays.sort(sorted); + return sorted[sorted.length / 2]; + } + + private static FrameBlock loadLineitem(File f) throws Exception { + System.out.print("Loading " + f.getPath() + " ... "); + List rows = new ArrayList<>(); + try(BufferedReader br = new BufferedReader(new FileReader(f))) { + String line; + while((line = br.readLine()) != null && rows.size() < MAX_ROWS) { + if(line.isEmpty()) + continue; + if(line.endsWith("|")) + line = line.substring(0, line.length() - 1); + rows.add(line.split("\\|", -1)); + } + } + String[][] data = rows.toArray(new String[0][]); + System.out.println(data.length + " rows loaded."); + return new FrameBlock(LINEITEM_SCHEMA, LINEITEM_NAMES, data); + } +} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java index 54945f1689a..cc1412b1606 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameParquetSchemaTest.java @@ -19,7 +19,6 @@ package org.apache.sysds.test.functions.io.parquet; -import org.junit.Assert; import org.junit.Test; import org.apache.sysds.test.TestUtils; @@ -56,123 +55,48 @@ public void setUp() { /** * Test for sequential writer and reader - * + * */ @Test - public void testParquetWriteReadAllSchemaTypes() { - String fname = output("Rout"); - - // Define a schema with one column per type - ValueType[] schema = new ValueType[] { - ValueType.FP64, - ValueType.FP32, - ValueType.INT32, - ValueType.INT64, - ValueType.BOOLEAN, - ValueType.STRING - }; - - // Create an empty frame block with the above schema - FrameBlock fb = new FrameBlock(schema); - - // Populate frame block - Object[][] rows = new Object[][] { - { 1.0, 1.1f, 10, 100L, true, "A" }, - { 2.0, 2.1f, 20, 200L, false, "B" }, - { 3.0, 3.1f, 30, 300L, true, "C" }, - { 4.0, 4.1f, 40, 400L, false, "D" }, - { 5.0, 5.1f, 50, 500L, true, "E" } - }; - - for (Object[] row : rows) { - fb.appendRow(row); - } - - System.out.println(fb); - - int numRows = fb.getNumRows(); - int numCols = fb.getNumColumns(); - - // Write the FrameBlock to a Parquet file using the sequential writer - try { - FrameWriter writer = new FrameWriterParquet(); - writer.writeFrameToHDFS(fb, fname, numRows, numCols); - } - catch (IOException e) { - e.printStackTrace(); - Assert.fail("Failed to write frame block to Parquet: " + e.getMessage()); - } - - // Read the Parquet file back into a new FrameBlock - FrameBlock fbRead = null; - try { - FrameReader reader = new FrameReaderParquet(); - String[] colNames = fb.getColumnNames(); - fbRead = reader.readFrameFromHDFS(fname, schema, colNames, numRows, numCols); - } - catch (IOException e) { - e.printStackTrace(); - Assert.fail("Failed to read frame block from Parquet: " + e.getMessage()); - } - - // Compare the original and the read frame blocks - TestUtils.compareFrames(fb, fbRead, false); + public void testParquetWriteReadAllSchemaTypes() throws IOException { + runWriteReadAllSchemaTypes(new FrameWriterParquet(), new FrameReaderParquet(), output("Rout")); } /** * Test for multithreaded writer and reader - * + * */ @Test - public void testParquetWriteReadAllSchemaTypesParallel() { - String fname = output("Rout_parallel"); - - ValueType[] schema = new ValueType[] { - ValueType.FP64, - ValueType.FP32, - ValueType.INT32, - ValueType.INT64, - ValueType.BOOLEAN, - ValueType.STRING - }; + public void testParquetWriteReadAllSchemaTypesParallel() throws IOException { + runWriteReadAllSchemaTypes(new FrameWriterParquetParallel(), new FrameReaderParquetParallel(), + output("Rout_parallel")); + } + private static void runWriteReadAllSchemaTypes(FrameWriter writer, FrameReader reader, String fname) + throws IOException { + // Define a schema with one column per type + ValueType[] schema = new ValueType[] {ValueType.FP64, ValueType.FP32, ValueType.INT32, ValueType.INT64, + ValueType.BOOLEAN, ValueType.STRING}; + + // Create an empty frame block with the above schema FrameBlock fb = new FrameBlock(schema); - Object[][] rows = new Object[][] { - { 1.0, 1.1f, 10, 100L, true, "A" }, - { 2.0, 2.1f, 20, 200L, false, "B" }, - { 3.0, 3.1f, 30, 300L, true, "C" }, - { 4.0, 4.1f, 40, 400L, false, "D" }, - { 5.0, 5.1f, 50, 500L, true, "E" } - }; + // Populate frame block + Object[][] rows = new Object[][] {{1.0, 1.1f, 10, 100L, true, "A"}, {2.0, 2.1f, 20, 200L, false, "B"}, + {3.0, 3.1f, 30, 300L, true, "C"}, {4.0, 4.1f, 40, 400L, false, "D"}, {5.0, 5.1f, 50, 500L, true, "E"}}; - for (Object[] row : rows) { + for(Object[] row : rows) fb.appendRow(row); - } int numRows = fb.getNumRows(); int numCols = fb.getNumColumns(); - try { - FrameWriter writer = new FrameWriterParquetParallel(); - writer.writeFrameToHDFS(fb, fname, numRows, numCols); - } - catch (IOException e) { - e.printStackTrace(); - Assert.fail("Failed to write frame block to Parquet (parallel): " + e.getMessage()); - } - - FrameBlock fbRead = null; - try { - FrameReader reader = new FrameReaderParquetParallel(); - String[] colNames = fb.getColumnNames(); - fbRead = reader.readFrameFromHDFS(fname, schema, colNames, numRows, numCols); - } - catch (IOException e) { - e.printStackTrace(); - Assert.fail("Failed to read frame block from Parquet (parallel): " + e.getMessage()); - } + writer.writeFrameToHDFS(fb, fname, numRows, numCols); + String[] colNames = fb.getColumnNames(); + FrameBlock fbRead = reader.readFrameFromHDFS(fname, schema, colNames, numRows, numCols); + + // Compare the original and the read frame blocks TestUtils.compareFrames(fb, fbRead, false); } } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java deleted file mode 100644 index 0b2510e4508..00000000000 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderParquetLegacy.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.sysds.test.functions.io.parquet; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.util.concurrent.TimeUnit; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.Path; -import org.apache.parquet.example.data.Group; -import org.apache.parquet.hadoop.ParquetFileReader; -import org.apache.parquet.hadoop.ParquetReader; -import org.apache.parquet.hadoop.example.GroupReadSupport; -import org.apache.parquet.hadoop.metadata.ParquetMetadata; -import org.apache.parquet.hadoop.util.HadoopInputFile; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.sysds.common.Types.ValueType; -import org.apache.sysds.conf.ConfigurationManager; -import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.frame.data.FrameBlock; -import org.apache.sysds.runtime.io.FrameReader; -import org.apache.sysds.runtime.util.HDFSTool; -import org.apache.parquet.io.api.Binary; - -/** - * Single-threaded frame parquet reader using the {@code Group} record API (legacy baseline). - * - */ -public class FrameReaderParquetLegacy extends FrameReader { - - /** - * Reads a Parquet file from HDFS and converts it into a FrameBlock. - * - * @param fname The HDFS file path to the Parquet file. - * @param schema The expected data types of the columns. - * @param names The names of the columns. - * @param rlen The expected number of rows. - * @param clen The expected number of columns. - * @return A FrameBlock containing the data read from the Parquet file. - */ - @Override - public FrameBlock readFrameFromHDFS(String fname, ValueType[] schema, String[] names, long rlen, long clen) throws IOException, DMLRuntimeException { - // Prepare file access - Configuration conf = ConfigurationManager.getCachedJobConf(); - Path path = new Path(fname); - - // Check existence and non-empty file - if (!HDFSTool.existsFileOnHDFS(path.toString())) { - throw new IOException("File does not exist on HDFS: " + fname); - } - - // Allocate output frame block - ValueType[] lschema = createOutputSchema(schema, clen); - String[] lnames = createOutputNames(names, clen); - FrameBlock ret = createOutputFrameBlock(lschema, lnames, rlen); - - // Read Parquet file - readParquetFrameFromHDFS(path, conf, ret, lschema, rlen, clen); - - return ret; - } - - /** - * Reads data from a Parquet file on HDFS and fills the provided FrameBlock. - * The method retrieves the Parquet schema from the file footer, maps the required column names - * to their corresponding indices, and then uses a ParquetReader to iterate over each row. - * Data is extracted based on the column type and set into the output FrameBlock. - * - * @param path The HDFS path to the Parquet file. - * @param conf The Hadoop configuration. - * @param dest The FrameBlock to populate with data. - * @param schema The expected value types for the output columns. - * @param rlen The expected number of rows. - * @param clen The expected number of columns. - */ - protected void readParquetFrameFromHDFS(Path path, Configuration conf, FrameBlock dest, ValueType[] schema, long rlen, long clen) throws IOException { - int row = readSingleParquetFile(path, conf, dest, clen, 0); - - // Check frame dimensions - if (row != rlen) { - throw new IOException("Mismatch in row count: expected " + rlen + ", but got " + row); - } - } - - // Constants for decoding legacy INT96 timestamps - private static final int JULIAN_EPOCH_OFFSET_DAYS = 2_440_588; - private static final long MILLIS_IN_DAY = TimeUnit.DAYS.toMillis(1); - private static final long NANOS_PER_MILLISECOND = TimeUnit.MILLISECONDS.toNanos(1); - - protected int readSingleParquetFile(Path path, Configuration conf, FrameBlock dest, - long clen, long rowOffset) throws IOException { - // Retrieve schema from Parquet footer - ParquetMetadata metadata; - try (ParquetFileReader reader = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) {metadata = reader.getFooter();} - MessageType parquetSchema = metadata.getFileMetaData().getSchema(); - - // Map column names to Parquet schema indices - String[] columnNames = dest.getColumnNames(); - int[] columnIndices = new int[columnNames.length]; - for (int i = 0; i < columnNames.length; i++) { - columnIndices[i] = parquetSchema.getFieldIndex(columnNames[i]); - } - - // Read data usind ParquetReader - try (ParquetReader rowReader = ParquetReader.builder(new GroupReadSupport(), path) - .withConf(conf) - .build()) { - - Group group; - int row = (int) rowOffset; - while ((group = rowReader.read()) != null) { - for (int col = 0; col < clen; col++) { - int colIndex = columnIndices[col]; - if (group.getFieldRepetitionCount(colIndex) > 0) { - PrimitiveType.PrimitiveTypeName type = parquetSchema.getType(columnNames[col]).asPrimitiveType().getPrimitiveTypeName(); - switch (type) { - case INT32: - dest.set(row, col, group.getInteger(colIndex, 0)); - break; - case INT64: - dest.set(row, col, group.getLong(colIndex, 0)); - break; - case FLOAT: - dest.set(row, col, group.getFloat(colIndex, 0)); - break; - case DOUBLE: - dest.set(row, col, group.getDouble(colIndex, 0)); - break; - case BOOLEAN: - dest.set(row, col, group.getBoolean(colIndex, 0)); - break; - case INT96: { - // Legacy INT96 timestamp (Spark/Impala), narrowed to epoch millis. - // See https://github.com/apache/parquet-format/blob/master/LogicalTypes.md#timestamp - Binary binary = group.getInt96(colIndex, 0); - ByteBuffer buf = ByteBuffer.wrap(binary.getBytes()).order(ByteOrder.LITTLE_ENDIAN); - long nanosOfDay = buf.getLong(); - int julianDay = buf.getInt(); - long millis = (julianDay - JULIAN_EPOCH_OFFSET_DAYS) * MILLIS_IN_DAY - + nanosOfDay / NANOS_PER_MILLISECOND; - dest.set(row, col, millis); - break; - } - case BINARY: - dest.set(row, col, group.getBinary(colIndex, 0).toStringUsingUTF8()); - break; - default: - throw new IOException("Unsupported data type: " + type); - } - } else { - dest.set(row, col, null); - } - } - row++; - } - return row - (int) rowOffset; - } - } - - //not implemented - @Override - public FrameBlock readFrameFromInputStream(InputStream is, ValueType[] schema, String[] names, long rlen, long clen) - throws IOException, DMLRuntimeException { - throw new UnsupportedOperationException("Unimplemented method 'readFrameFromInputStream'"); - } -} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java index b4d87e9f905..0a8f9156122 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameReaderWriterParquetTest.java @@ -19,6 +19,7 @@ package org.apache.sysds.test.functions.io.parquet; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; import java.io.IOException; @@ -38,9 +39,8 @@ public class FrameReaderWriterParquetTest { private static final String FILENAME = "target/testTemp/functions/io/parquet/FrameReaderWriterParquetTest/frame.parquet"; // Parquet-supported value types - private static final ValueType[] SCHEMA = { - ValueType.FP64, ValueType.FP32, ValueType.INT32, ValueType.INT64, ValueType.BOOLEAN, ValueType.STRING - }; + private static final ValueType[] SCHEMA = {ValueType.FP64, ValueType.FP32, ValueType.INT32, ValueType.INT64, + ValueType.BOOLEAN, ValueType.STRING}; @Test public void testSingleRowSingleCol() throws IOException { @@ -69,17 +69,16 @@ public void testLargerFrame() throws IOException { @Test public void testValueTypeEdgeCases() throws IOException { - // type min/max, empty string, special chars (comma/quote/newline/unicode) - ValueType[] schema = { ValueType.FP32, ValueType.FP64, ValueType.INT32, ValueType.INT64, - ValueType.BOOLEAN, ValueType.STRING }; - String[] names = { "f32", "f64", "i32", "i64", "b", "s" }; + // type min/max, empty string, special chars (comma/quote/newline/unicode), column name with space + ValueType[] schema = {ValueType.FP32, ValueType.FP64, ValueType.INT32, ValueType.INT64, ValueType.BOOLEAN, + ValueType.STRING}; + String[] names = {"f 32", "f64", "i32", "i64", "b", "s"}; String[][] data = { - { String.valueOf(Float.MAX_VALUE), String.valueOf(Double.MAX_VALUE), - String.valueOf(Integer.MAX_VALUE), String.valueOf(Long.MAX_VALUE), "true", "" }, - { String.valueOf(-Float.MAX_VALUE), String.valueOf(-Double.MAX_VALUE), - String.valueOf(Integer.MIN_VALUE), String.valueOf(Long.MIN_VALUE), "false", "a,b\"c\nd" }, - { "0.0", "0.0", "0", "0", "true", "unicode_é中" } - }; + {String.valueOf(Float.MAX_VALUE), String.valueOf(Double.MAX_VALUE), String.valueOf(Integer.MAX_VALUE), + String.valueOf(Long.MAX_VALUE), "true", ""}, + {String.valueOf(-Float.MAX_VALUE), String.valueOf(-Double.MAX_VALUE), String.valueOf(Integer.MIN_VALUE), + String.valueOf(Long.MIN_VALUE), "false", "a,b\"c\nd"}, + {"0.0", "0.0", "0", "0", "true", "unicode_é中"}}; FrameBlock in = new FrameBlock(schema, names, data); new FrameWriterParquet().writeFrameToHDFS(in, FILENAME, 3, 6); @@ -91,14 +90,10 @@ public void testValueTypeEdgeCases() throws IOException { @Test public void testNullsInStringColumn() throws IOException { // Numeric columns from String[][] convert null to 0. Only test string nulls here. - // Numeric null round-trips are tested in ReadParquetTest. - ValueType[] schema = { ValueType.STRING, ValueType.STRING }; - String[] names = { "a", "b" }; - String[][] data = { - { "x", "y" }, - { null, null }, - { "p", null } - }; + // Numeric nulls are covered by the Spark-written userdata1/all files in ReadParquetTest. + ValueType[] schema = {ValueType.STRING, ValueType.STRING}; + String[] names = {"a", "b"}; + String[][] data = {{"x", "y"}, {null, null}, {"p", null}}; FrameBlock in = new FrameBlock(schema, names, data); new FrameWriterParquet().writeFrameToHDFS(in, FILENAME, 3, 2); @@ -112,6 +107,29 @@ public void testNullsInStringColumn() throws IOException { TestUtils.compareFrames(in, out, false); } + @Test + public void testMismatchedSchemaRead() throws IOException { + // requested frame types differ from the parquet physical types -> per-cell conversion path + ValueType[] writeSchema = {ValueType.INT32, ValueType.FP32, ValueType.BOOLEAN, ValueType.FP64, + ValueType.STRING}; + String[] names = {"i32", "f32", "b", "f64", "s"}; + String[][] data = {{"1", "1.5", "true", "2.5", "7"}, {"2", "2.5", "false", "3.5", null}}; + FrameBlock in = new FrameBlock(writeSchema, names, data); + + new FrameWriterParquet().writeFrameToHDFS(in, FILENAME, 2, 5); + + ValueType[] readSchema = {ValueType.INT64, ValueType.FP64, ValueType.STRING, ValueType.STRING, ValueType.FP64}; + FrameBlock out = new FrameReaderParquet().readFrameFromHDFS(FILENAME, readSchema, names, 2, 5); + + assertEquals(1L, out.get(0, 0)); + assertEquals(1.5, ((Number) out.get(0, 1)).doubleValue(), 0.0); + assertEquals("true", out.get(0, 2)); + assertEquals("2.5", out.get(0, 3)); + assertEquals(7.0, ((Number) out.get(0, 4)).doubleValue(), 0.0); + // numeric nulls keep the array default + assertEquals(0.0, ((Number) out.get(1, 4)).doubleValue(), 0.0); + } + private void runWriteReadRoundTrip(int rows, int cols, long seed) throws IOException { try { ValueType[] schema = new ValueType[cols]; @@ -121,8 +139,8 @@ private void runWriteReadRoundTrip(int rows, int cols, long seed) throws IOExcep FrameBlock writeBlock = TestUtils.generateRandomFrameBlock(rows, schema, seed); new FrameWriterParquet().writeFrameToHDFS(writeBlock, FILENAME, rows, cols); - FrameBlock readBlock = new FrameReaderParquet() - .readFrameFromHDFS(FILENAME, schema, writeBlock.getColumnNames(), rows, cols); + FrameBlock readBlock = new FrameReaderParquet().readFrameFromHDFS(FILENAME, schema, + writeBlock.getColumnNames(), rows, cols); TestUtils.compareFrames(writeBlock, readBlock, false); } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java deleted file mode 100755 index e57e2c99846..00000000000 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/FrameWriterParquetLegacy.java +++ /dev/null @@ -1,212 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.sysds.test.functions.io.parquet; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.fs.Path; -import org.apache.hadoop.mapred.JobConf; -import org.apache.parquet.example.data.Group; -import org.apache.parquet.example.data.simple.SimpleGroupFactory; -import org.apache.parquet.hadoop.ParquetWriter; -import org.apache.parquet.hadoop.example.ExampleParquetWriter; -import org.apache.parquet.schema.LogicalTypeAnnotation; -import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; -import org.apache.parquet.schema.Types; -import org.apache.sysds.conf.ConfigurationManager; -import org.apache.sysds.runtime.DMLRuntimeException; -import org.apache.sysds.runtime.frame.data.FrameBlock; -import org.apache.sysds.runtime.io.FrameWriter; -import org.apache.sysds.runtime.io.IOUtilFunctions; -import org.apache.sysds.runtime.util.HDFSTool; -import org.apache.sysds.common.Types.ValueType; - -/** - * Single-threaded frame parquet writer. - * - */ -public class FrameWriterParquetLegacy extends FrameWriter { - - private final int batchSizeOverride; - - public FrameWriterParquetLegacy() { - this.batchSizeOverride = 1000; - } - - public FrameWriterParquetLegacy(int batchSize) { - this.batchSizeOverride = batchSize; - } - - /** - * Writes a FrameBlock to a Parquet file on HDFS. - * - * @param src The FrameBlock containing the data to write. - * @param fname The HDFS file path where the Parquet file will be stored. - * @param rlen The expected number of rows. - * @param clen The expected number of columns. - */ - @Override - public final void writeFrameToHDFS(FrameBlock src, String fname, long rlen, long clen) throws IOException, DMLRuntimeException { - // Prepare file access - JobConf conf = ConfigurationManager.getCachedJobConf(); - Path path = new Path(fname); - - // If the file already exists on HDFS, remove it - HDFSTool.deleteFileIfExistOnHDFS(path, conf); - - // Check frame dimensions - if (src.getNumRows() != rlen || src.getNumColumns() != clen) { - throw new IOException("Frame dimensions mismatch with metadata: " + src.getNumRows() + "x" + src.getNumColumns() + " vs " + rlen + "x" + clen + "."); - } - - // Write parquet file - writeParquetFrameToHDFS(path, conf, src); - } - - /** - * Writes the FrameBlock data to a Parquet file using a ParquetWriter. - * The method generates a Parquet schema based on the metadata of the FrameBlock, initializes a ParquetWriter with specified configurations, - * iterates over each row and column, adding values (in batches for improved performance) using type-specific conversions. - * - * @param path The HDFS path where the Parquet file will be written. - * @param conf The Hadoop configuration. - * @param src The FrameBlock containing the data to write. - */ - protected void writeParquetFrameToHDFS(Path path, Configuration conf, FrameBlock src) - throws IOException - { - FileSystem fs = IOUtilFunctions.getFileSystem(path, conf); - - // Create schema based on frame block metadata - MessageType schema = createParquetSchema(src); - - int batchSize = batchSizeOverride; - int rowCount = 0; - - // Write data using ParquetWriter //FIXME replace example writer? - try (ParquetWriter writer = ExampleParquetWriter.builder(path) - .withConf(conf) - .withType(schema) - .withCompressionCodec(ParquetWriter.DEFAULT_COMPRESSION_CODEC_NAME) - .withRowGroupSize((long) ParquetWriter.DEFAULT_BLOCK_SIZE) - .withPageSize(ParquetWriter.DEFAULT_PAGE_SIZE) - .withDictionaryEncoding(true) - .build()) - { - - SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema); - - List rowBuffer = new ArrayList<>(batchSize); - - for (int i = 0; i < src.getNumRows(); i++) { - Group group = groupFactory.newGroup(); - for (int j = 0; j < src.getNumColumns(); j++) { - Object value = src.get(i, j); - if (value != null) { - ValueType type = src.getSchema()[j]; - switch (type) { - case STRING: - group.add(src.getColumnNames()[j], value.toString()); - break; - case INT32: - group.add(src.getColumnNames()[j], (int) value); - break; - case INT64: - group.add(src.getColumnNames()[j], (long) value); - break; - case FP32: - group.add(src.getColumnNames()[j], (float) value); - break; - case FP64: - group.add(src.getColumnNames()[j], (double) value); - break; - case BOOLEAN: - group.add(src.getColumnNames()[j], (boolean) value); - break; - default: - throw new IOException("Unsupported value type: " + type); - } - } - } - rowBuffer.add(group); - rowCount++; - - if (rowCount >= batchSize) { - for (Group g : rowBuffer) { - writer.write(g); - } - rowBuffer.clear(); - rowCount = 0; - } - } - - for (Group g : rowBuffer) { - writer.write(g); - } - } - - // Delete CRC files created by Hadoop if necessary - IOUtilFunctions.deleteCrcFilesFromLocalFileSystem(fs, path); - } - - /** - * Creates a Parquet schema based on the metadata of a FrameBlock. - * - * @param src The FrameBlock whose metadata is used to create the Parquet schema. - * @return The generated Parquet MessageType schema. - */ - protected MessageType createParquetSchema(FrameBlock src) { - String[] columnNames = src.getColumnNames(); - ValueType[] columnTypes = src.getSchema(); - Types.MessageTypeBuilder builder = Types.buildMessage(); - - for (int i = 0; i < src.getNumColumns(); i++) { - switch (columnTypes[i]) { - case STRING: - builder.optional(PrimitiveTypeName.BINARY) - .as(LogicalTypeAnnotation.stringType()) - .named(columnNames[i]); - break; - case INT32: - builder.optional(PrimitiveTypeName.INT32).named(columnNames[i]); - break; - case INT64: - builder.optional(PrimitiveTypeName.INT64).named(columnNames[i]); - break; - case FP32: - builder.optional(PrimitiveTypeName.FLOAT).named(columnNames[i]); - break; - case FP64: - builder.optional(PrimitiveTypeName.DOUBLE).named(columnNames[i]); - break; - case BOOLEAN: - builder.optional(PrimitiveTypeName.BOOLEAN).named(columnNames[i]); - break; - default: - throw new IllegalArgumentException("Unsupported data type: " + columnTypes[i]); - } - } - return builder.named("FrameSchema"); - } -} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java index 9ce52887cee..0ae2145839c 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetTestUtils.java @@ -37,6 +37,7 @@ import org.apache.parquet.hadoop.util.HadoopInputFile; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.PrimitiveType; +import org.apache.spark.sql.DataFrameWriter; import org.apache.spark.sql.Dataset; import org.apache.spark.sql.Row; import org.apache.spark.sql.RowFactory; @@ -48,6 +49,7 @@ import org.apache.spark.sql.types.StructType; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.conf.ConfigurationManager; +import org.apache.sysds.runtime.frame.data.FrameBlock; import org.apache.sysds.test.AutomatedTestBase; class ParquetTestUtils { @@ -64,7 +66,7 @@ static ParquetMetadataInfo inferMetadata(String fname) throws IOException { Path path = new Path(fname); ParquetMetadata metadata; - try (ParquetFileReader r = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) { + try(ParquetFileReader r = ParquetFileReader.open(HadoopInputFile.fromPath(path, conf))) { metadata = r.getFooter(); } MessageType parquetSchema = metadata.getFileMetaData().getSchema(); @@ -73,24 +75,35 @@ static ParquetMetadataInfo inferMetadata(String fname) throws IOException { String[] names = new String[fieldCount]; ValueType[] schema = new ValueType[fieldCount]; - for (int i = 0; i < fieldCount; i++) { + for(int i = 0; i < fieldCount; i++) { names[i] = parquetSchema.getFieldName(i); PrimitiveType.PrimitiveTypeName type = parquetSchema.getType(i).asPrimitiveType().getPrimitiveTypeName(); - switch (type) { - case INT32: schema[i] = ValueType.INT32; break; - case INT64: schema[i] = ValueType.INT64; break; - case FLOAT: schema[i] = ValueType.FP32; break; - case DOUBLE: schema[i] = ValueType.FP64; break; - case BOOLEAN: schema[i] = ValueType.BOOLEAN; break; - case BINARY: schema[i] = ValueType.STRING; break; - case INT96: schema[i] = ValueType.INT64; break; + switch(type) { + case INT32: + schema[i] = ValueType.INT32; + break; + case INT64: + schema[i] = ValueType.INT64; + break; + case FLOAT: + schema[i] = ValueType.FP32; + break; + case DOUBLE: + schema[i] = ValueType.FP64; + break; + case BOOLEAN: + schema[i] = ValueType.BOOLEAN; + break; + case BINARY: + schema[i] = ValueType.STRING; + break; default: throw new IOException("Unsupported parquet type: " + type + " in column " + names[i]); } } long rlen = 0; - for (BlockMetaData block : metadata.getBlocks()) + for(BlockMetaData block : metadata.getBlocks()) rlen += block.getRowCount(); ParquetMetadataInfo info = new ParquetMetadataInfo(); @@ -101,46 +114,93 @@ static ParquetMetadataInfo inferMetadata(String fname) throws IOException { return info; } + static SparkSession sparkSession() { + return AutomatedTestBase.createSystemDSSparkSession("parquet-frame-tests", "local[1]"); + } + + /** + * Reads a parquet file (or directory of part files) through Spark, into a FrameBlock with the given column layout, + * projecting the columns in the requested order. + * + * @param fname parquet file or directory + * @param schema value types of the requested columns + * @param names names of the requested columns + * @return the frame as read by Spark + */ + static FrameBlock sparkReadAsFrame(String fname, ValueType[] schema, String[] names) { + Dataset df = sparkSession().read().parquet(fname).select(names[0], + Arrays.copyOfRange(names, 1, names.length)); + return toFrameBlock(df.collectAsList(), schema, names); + } + + /** Converts collected Spark rows into a FrameBlock */ + static FrameBlock toFrameBlock(List rows, ValueType[] schema, String[] names) { + FrameBlock fb = new FrameBlock(schema, names); + fb.ensureAllocatedColumns(rows.size()); + for(int r = 0; r < rows.size(); r++) + for(int c = 0; c < schema.length; c++) + fb.set(r, c, rows.get(r).get(c)); + return fb; + } + /** - * Generates the public test files (userdata1, alltypes_plain, all) with Spark's - * DataFrameWriter. userdata1 and alltypes_plain each include a TimestampType column, - * which Spark 3.5 encodes as INT96 by default. + * Generates the public test files (userdata1, alltypes_plain, all) with Spark's DataFrameWriter, covering all + * supported physical types and null values. One file is gzip-compressed to also cover a non-default codec on read; + * the others use Spark's default (snappy). * * @param outDir directory the generated files are written into * @return map from file name (e.g. "userdata1") to its generated file path */ static Map generatePublicTestFiles(File outDir) throws Exception { - SparkSession spark = AutomatedTestBase.createSystemDSSparkSession("parquet-test-files", "local[1]"); + SparkSession spark = sparkSession(); Map files = new LinkedHashMap<>(); - files.put("userdata1", writeTestFile(spark, outDir, "userdata1", userdata1Rows(), userdata1Schema())); - files.put("alltypes_plain", writeTestFile(spark, outDir, "alltypes_plain", alltypesPlainRows(), alltypesPlainSchema())); - files.put("all", writeTestFile(spark, outDir, "all", allRows(), allSchema())); + files.put("userdata1", writeTestFile(spark, outDir, "userdata1", userdata1Rows(), userdata1Schema(), null)); + files.put("alltypes_plain", + writeTestFile(spark, outDir, "alltypes_plain", alltypesPlainRows(), alltypesPlainSchema(), "gzip")); + files.put("all", writeTestFile(spark, outDir, "all", allRows(), allSchema(), null)); return files; } + /** + * Writes a file containing a deprecated INT96 timestamp column (id, ts, name), forced via + * spark.sql.parquet.outputTimestampType so the encoding does not depend on the Spark version. + * + * @param outDir directory the file is written into + * @return the generated file path + */ + static String writeInt96TimestampFile(File outDir) throws IOException { + SparkSession spark = sparkSession(); + StructType schema = new StructType( + new StructField[] {new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("ts", DataTypes.TimestampType, true, Metadata.empty()), + new StructField("name", DataTypes.StringType, true, Metadata.empty()),}); + List rows = Arrays.asList(RowFactory.create(1, Timestamp.valueOf("2016-02-03 07:55:29"), "Amanda"), + RowFactory.create(2, Timestamp.valueOf("2016-02-03 17:04:03"), "Albert"), + RowFactory.create(3, null, "Evelyn")); + spark.conf().set("spark.sql.parquet.outputTimestampType", "INT96"); + try { + return writeTestFile(spark, outDir, "int96", rows, schema, null); + } + finally { + spark.conf().unset("spark.sql.parquet.outputTimestampType"); + } + } + private static StructType userdata1Schema() { - return new StructType(new StructField[] { - new StructField("registration_dttm", DataTypes.TimestampType, true, Metadata.empty()), - new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + return new StructType(new StructField[] {new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), new StructField("first_name", DataTypes.StringType, true, Metadata.empty()), - new StructField("salary", DataTypes.DoubleType, true, Metadata.empty()), - }); + new StructField("salary", DataTypes.DoubleType, true, Metadata.empty()),}); } private static List userdata1Rows() { - return Arrays.asList( - RowFactory.create(Timestamp.valueOf("2016-02-03 07:55:29"), 1, "Amanda", 49756.53), - RowFactory.create(Timestamp.valueOf("2016-02-03 17:04:03"), 2, "Albert", 150280.17), - RowFactory.create(Timestamp.valueOf("2016-02-03 01:09:31"), 3, "Evelyn", 144972.51), - RowFactory.create((Timestamp) null, 4, "Denise", 90263.05), - RowFactory.create(Timestamp.valueOf("2016-02-03 05:07:25"), 5, "Carlos", 75500.34) - ); + return Arrays.asList(RowFactory.create(1, "Amanda", 49756.53), RowFactory.create(2, "Albert", 150280.17), + RowFactory.create(3, "Evelyn", 144972.51), RowFactory.create(4, "Denise", null), + RowFactory.create(5, "Carlos", 75500.34)); } private static StructType alltypesPlainSchema() { - return new StructType(new StructField[] { - new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), + return new StructType(new StructField[] {new StructField("id", DataTypes.IntegerType, true, Metadata.empty()), new StructField("bool_col", DataTypes.BooleanType, true, Metadata.empty()), new StructField("tinyint_col", DataTypes.IntegerType, true, Metadata.empty()), new StructField("smallint_col", DataTypes.IntegerType, true, Metadata.empty()), @@ -148,58 +208,55 @@ private static StructType alltypesPlainSchema() { new StructField("float_col", DataTypes.FloatType, true, Metadata.empty()), new StructField("double_col", DataTypes.DoubleType, true, Metadata.empty()), new StructField("date_string_col", DataTypes.StringType, true, Metadata.empty()), - new StructField("string_col", DataTypes.StringType, true, Metadata.empty()), - new StructField("timestamp_col", DataTypes.TimestampType, true, Metadata.empty()), - }); + new StructField("string_col", DataTypes.StringType, true, Metadata.empty()),}); } private static List alltypesPlainRows() { - return Arrays.asList( - RowFactory.create(1, true, 1, 10, 100L, 1.5f, 2.25, "03/01/09", "row-1", Timestamp.valueOf("2009-03-01 00:00:00")), - RowFactory.create(2, false, 2, 20, 200L, 2.5f, 4.5, "03/02/09", "row-2", Timestamp.valueOf("2009-03-02 05:15:30")), - RowFactory.create(3, true, 3, 30, 300L, 3.5f, 6.75, "03/03/09", "row-3", Timestamp.valueOf("2009-03-03 10:30:00")), - RowFactory.create(4, false, 4, 40, 400L, 4.5f, 9.0, "03/04/09", "row-4", Timestamp.valueOf("2009-03-04 15:45:15")), - RowFactory.create(5, true, 5, 50, 500L, 5.5f, 11.25, "03/05/09", "row-5", Timestamp.valueOf("2009-03-05 21:00:45")), - RowFactory.create(6, false, 6, 60, 600L, 6.5f, 13.5, "03/06/09", "row-6", Timestamp.valueOf("2009-03-06 02:20:10")), - RowFactory.create(7, true, 7, 70, 700L, 7.5f, 15.75, "03/07/09", "row-7", Timestamp.valueOf("2009-03-07 08:35:50")), - RowFactory.create(8, false, 8, 80, 800L, 8.5f, 18.0, "03/08/09", "row-8", Timestamp.valueOf("2009-03-08 13:50:25")) - ); + return Arrays.asList(RowFactory.create(1, true, 1, 10, 100L, 1.5f, 2.25, "03/01/09", "row-1"), + RowFactory.create(2, false, 2, 20, 200L, 2.5f, 4.5, "03/02/09", "row-2"), + RowFactory.create(3, true, 3, 30, 300L, 3.5f, 6.75, "03/03/09", "row-3"), + RowFactory.create(4, false, 4, 40, 400L, 4.5f, 9.0, "03/04/09", "row-4"), + RowFactory.create(5, true, 5, 50, 500L, 5.5f, 11.25, "03/05/09", "row-5"), + RowFactory.create(6, false, 6, 60, 600L, 6.5f, 13.5, "03/06/09", "row-6"), + RowFactory.create(7, true, 7, 70, 700L, 7.5f, 15.75, "03/07/09", "row-7"), + RowFactory.create(8, false, 8, 80, 800L, 8.5f, 18.0, "03/08/09", "row-8")); } private static StructType allSchema() { - return new StructType(new StructField[] { - new StructField("PassengerId", DataTypes.IntegerType, true, Metadata.empty()), - new StructField("Survived", DataTypes.IntegerType, true, Metadata.empty()), - new StructField("Pclass", DataTypes.IntegerType, true, Metadata.empty()), - new StructField("Name", DataTypes.StringType, true, Metadata.empty()), - new StructField("Sex", DataTypes.StringType, true, Metadata.empty()), - new StructField("Age", DataTypes.DoubleType, true, Metadata.empty()), - new StructField("Fare", DataTypes.DoubleType, true, Metadata.empty()), - new StructField("Embarked", DataTypes.StringType, true, Metadata.empty()), - }); + return new StructType( + new StructField[] {new StructField("PassengerId", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("Survived", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("Pclass", DataTypes.IntegerType, true, Metadata.empty()), + new StructField("Name", DataTypes.StringType, true, Metadata.empty()), + new StructField("Sex", DataTypes.StringType, true, Metadata.empty()), + new StructField("Age", DataTypes.DoubleType, true, Metadata.empty()), + new StructField("Fare", DataTypes.DoubleType, true, Metadata.empty()), + new StructField("Embarked", DataTypes.StringType, true, Metadata.empty()),}); } private static List allRows() { - return Arrays.asList( - RowFactory.create(1, 0, 3, "Braund, Mr. Owen Harris", "male", 22.0, 7.25, "S"), + return Arrays.asList(RowFactory.create(1, 0, 3, "Braund, Mr. Owen Harris", "male", 22.0, 7.25, "S"), RowFactory.create(2, 1, 1, "Cumings, Mrs. John Bradley", "female", 38.0, 71.2833, "C"), RowFactory.create(3, 1, 3, "Heikkinen, Miss. Laina", "female", 26.0, 7.925, "S"), RowFactory.create(4, 1, 1, "Futrelle, Mrs. Jacques Heath", "female", 35.0, 53.1, "S"), RowFactory.create(5, 0, 3, "Allen, Mr. William Henry", "male", null, 8.05, "S"), RowFactory.create(6, 0, 3, "Moran, Mr. James", "male", null, 8.4583, "Q"), RowFactory.create(7, 0, 1, "McCarthy, Mr. Timothy J", "male", 54.0, 51.8625, "S"), - RowFactory.create(8, 0, 3, "Palsson, Master. Gosta Leonard", "male", 2.0, 21.075, "S") - ); + RowFactory.create(8, 0, 3, "Palsson, Master. Gosta Leonard", "male", 2.0, 21.075, "S")); } // Spark writes a directory of part files, so we force one partition and rename it to a single file. - private static String writeTestFile(SparkSession spark, File outDir, String name, List rows, StructType schema) throws IOException { + private static String writeTestFile(SparkSession spark, File outDir, String name, List rows, StructType schema, + String codec) throws IOException { Dataset df = spark.createDataFrame(rows, schema); File tmpDir = new File(outDir, "_tmp_" + name); - df.coalesce(1).write().mode(SaveMode.Overwrite).parquet(tmpDir.getPath()); + DataFrameWriter writer = df.coalesce(1).write().mode(SaveMode.Overwrite); + if(codec != null) + writer = writer.option("compression", codec); + writer.parquet(tmpDir.getPath()); File[] parts = tmpDir.listFiles((d, n) -> n.startsWith("part-") && n.endsWith(".parquet")); - if (parts == null || parts.length != 1) + if(parts == null || parts.length != 1) throw new IOException("expected exactly 1 part file in " + tmpDir); File dest = new File(outDir, name + ".parquet"); @@ -210,8 +267,8 @@ private static String writeTestFile(SparkSession spark, File outDir, String name private static void deleteRecursive(File f) { File[] children = f.listFiles(); - if (children != null) - for (File c : children) + if(children != null) + for(File c : children) deleteRecursive(c); f.delete(); } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java deleted file mode 100644 index 902931a5adf..00000000000 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/ParquetWriterBenchmark.java +++ /dev/null @@ -1,274 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.sysds.test.functions.io.parquet; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.FileWriter; -import java.io.PrintWriter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -import org.apache.parquet.hadoop.metadata.CompressionCodecName; -import org.apache.sysds.common.Types.ValueType; -import org.apache.sysds.runtime.frame.data.FrameBlock; -import org.apache.sysds.runtime.io.FrameWriterParquet; -import org.apache.sysds.runtime.io.FrameWriterParquet.DictEncoding; -import org.junit.After; -import org.junit.Assume; - -/** - * Parquet writer benchmark using the TPC-H lineitem dataset. - * Writes results to temp/benchmark_results.csv for plotting. - * - * The benchmark methods are disabled by default; uncomment to run manually. - * If a dataset is not present inside the temp dir the corresponding test is skipped with instructions. - * - * The default maxRows=2_000_000, to benchmark row-group - * size properly, run on a machine with enough RAM for a larger frame: - * # 1. Generate a larger TPC-H lineitem into temp/lineitem.tbl - * # 2. Raise the maxRows cap, then run the benchmark: - * mvn test -Dtest=ParquetWriterBenchmark#benchmarkRowGroupSizes \ - * -DmaxRows=60000000 -DargLine="-Xms24g -Xmx24g" -DfailIfNoTests=false - */ -public class ParquetWriterBenchmark { - - private static final String TPCH_FILE = "temp/lineitem.tbl"; - private static final String RESULTS_CSV = "temp/benchmark_results.csv"; - private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_tpch_bench.parquet"; - private static final int RUNS = 3; - private static final int MAX_ROWS = Integer.getInteger("maxRows", 2_000_000); - private static final int[] BATCH_SIZES = { 1, 100, 500, 1000, 5000, 10_000, 50_000, 100_000, 200_000 }; - private static final long[] ROW_GROUP_SIZES = { - 1024 * 1024, // 1 MB - 8L * 1024 * 1024, // 8 MB - 16L * 1024 * 1024, // 16 MB - 32L * 1024 * 1024, // 32 MB - 64L * 1024 * 1024, // 64 MB - 128L * 1024 * 1024, // 128 MB (Parquet default) - 256L * 1024 * 1024, // 256 MB - 512L * 1024 * 1024 // 512 MB - }; - - // TPC-H lineitem schema - private static final ValueType[] LINEITEM_SCHEMA = { - ValueType.INT64, // orderkey - ValueType.INT64, // partkey - ValueType.INT64, // suppkey - ValueType.INT32, // linenumber - ValueType.FP64, // quantity - ValueType.FP64, // extendedprice - ValueType.FP64, // discount - ValueType.FP64, // tax - ValueType.STRING, // returnflag (3 unique values) - ValueType.STRING, // linestatus (2 unique values) - ValueType.STRING, // shipdate - ValueType.STRING, // commitdate - ValueType.STRING, // receiptdate - ValueType.STRING, // shipinstruct (4 unique values) - ValueType.STRING, // shipmode (7 unique values) - ValueType.STRING // comment - }; - - private static final String[] LINEITEM_NAMES = { - "orderkey", "partkey", "suppkey", "linenumber", - "quantity", "extendedprice", "discount", "tax", - "returnflag", "linestatus", "shipdate", "commitdate", - "receiptdate", "shipinstruct", "shipmode", "comment" - }; - - private PrintWriter csv; - - @After - public void cleanup() { - new File(TEMP_FILE).delete(); - if (csv != null) csv.close(); - } - - // @Test - public void benchmarkWriters() throws Exception { - FrameBlock data = loadOrSkip(); - int rows = data.getNumRows(); - - openCsv(); - System.out.println("\n=== TPC-H Writer Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); - System.out.printf("%-38s %12s %15s%n", "Configuration", "Time (ms)", "Rows/sec"); - System.out.println("-".repeat(70)); - - FrameWriterParquet newWriter = new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_ON); - - // Warmup - new File(TEMP_FILE).delete(); - new FrameWriterParquetLegacy(1000).writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); - new File(TEMP_FILE).delete(); - newWriter.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); - - for (int batchSize : BATCH_SIZES) { - long[] times = new long[RUNS]; - for (int run = 0; run < RUNS; run++) { - new File(TEMP_FILE).delete(); - long start = System.currentTimeMillis(); - new FrameWriterParquetLegacy(batchSize).writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); - times[run] = System.currentTimeMillis() - start; - } - long med = median(times); - String label = "Legacy batchSize=" + batchSize; - System.out.printf("%-38s %12d %15.0f%n", label, med, rows * 1000.0 / med); - csv.printf("batch_sizes,%s,%d,%.0f,,%n", label, med, rows * 1000.0 / med); - } - - System.out.println("-".repeat(70)); - - long[] times = new long[RUNS]; - for (int run = 0; run < RUNS; run++) { - new File(TEMP_FILE).delete(); - long start = System.currentTimeMillis(); - newWriter.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); - times[run] = System.currentTimeMillis() - start; - } - long med = median(times); - System.out.printf("%-38s %12d %15.0f%n", "New WriteSupport", med, rows * 1000.0 / med); - csv.printf("batch_sizes,New WriteSupport,%d,%.0f,,%n", med, rows * 1000.0 / med); - System.out.println(); - } - - // @Test - public void benchmarkDictionaryEncoding() throws Exception { - FrameBlock data = loadOrSkip(); - int rows = data.getNumRows(); - - openCsv(); - System.out.println("\n=== TPC-H Dictionary Encoding Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); - System.out.printf("%-20s %12s %15s%n", "Strategy", "Time (ms)", "Rows/sec"); - System.out.println("-".repeat(52)); - - time("encoding", "ALL_ON", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_ON), data, rows); - time("encoding", "ALL_OFF", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.ALL_OFF), data, rows); - time("encoding", "STRING_ONLY", new FrameWriterParquet(CompressionCodecName.UNCOMPRESSED, DictEncoding.STRING_ONLY), data, rows); - System.out.println(); - } - - - // @Test - public void benchmarkRowGroupSizes() throws Exception { - FrameBlock data = loadOrSkip(); - int rows = data.getNumRows(); - - openCsv(); - System.out.println("\n=== TPC-H Row Group Size Benchmark (" + rows + " rows, median of " + RUNS + " runs) ===\n"); - System.out.printf("%-20s %12s %15s%n", "Row Group Size", "Time (ms)", "Rows/sec"); - System.out.println("-".repeat(52)); - - for (long rowGroupSize : ROW_GROUP_SIZES) { - String label = (rowGroupSize / (1024 * 1024)) + "MB"; - time("row_group_sizes", label, - new FrameWriterParquet(CompressionCodecName.ZSTD, DictEncoding.ALL_ON, rowGroupSize), data, rows); - } - System.out.println(); - } - - private FrameBlock loadOrSkip() throws Exception { - File f = new File(TPCH_FILE); - if (!f.exists()) { - System.out.println(); - System.out.println("==================================================="); - System.out.println("TPC-H benchmark skipped, dataset not found"); - System.out.println("To reproduce:"); - System.out.println(" 1. Install DuckDB: https://duckdb.org"); - System.out.println(" 2. Open shell: duckdb"); - System.out.println(" 3. Run in DuckDB: INSTALL tpch;"); - System.out.println(" LOAD tpch;"); - System.out.println(" CALL dbgen(sf=1);"); - System.out.println(" COPY lineitem TO '/temp/lineitem.tbl'"); - System.out.println(" (DELIMITER '|', HEADER false);"); - System.out.println("==================================================="); - Assume.assumeTrue("TPC-H dataset not found at " + TPCH_FILE, false); - } - return loadLineitem(f); - } - - private void openCsv() throws Exception { - new File("temp").mkdirs(); - boolean exists = new File(RESULTS_CSV).exists(); - csv = new PrintWriter(new FileWriter(RESULTS_CSV, true)); - if (!exists) - csv.println("benchmark,label,time_ms,rows_per_sec,size_mb,compression_ratio"); - csv.flush(); - } - - private void time(String category, String label, FrameWriterParquet writer, FrameBlock data, int rows) throws Exception { - new File(TEMP_FILE).delete(); - writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); // warmup - - long[] times = new long[RUNS]; - for (int run = 0; run < RUNS; run++) { - new File(TEMP_FILE).delete(); - long start = System.currentTimeMillis(); - writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); - times[run] = System.currentTimeMillis() - start; - } - long med = median(times); - System.out.printf("%-20s %12d %15.0f%n", label, med, rows * 1000.0 / med); - csv.printf("%s,%s,%d,%.0f,,%n", category, label, med, rows * 1000.0 / med); - } - - private long timeWithSize(String category, String label, FrameWriterParquet writer, FrameBlock data, int rows, long baseSize) throws Exception { - new File(TEMP_FILE).delete(); - writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); // warmup - - long[] times = new long[RUNS]; - for (int run = 0; run < RUNS; run++) { - new File(TEMP_FILE).delete(); - long start = System.currentTimeMillis(); - writer.writeFrameToHDFS(data, TEMP_FILE, rows, data.getNumColumns()); - times[run] = System.currentTimeMillis() - start; - } - long med = median(times); - long size = new File(TEMP_FILE).length(); - double mb = size / (1024.0 * 1024.0); - String ratio = baseSize > 0 ? String.format("%.2fx", (double) baseSize / size) : "baseline"; - System.out.printf("%-20s %12d %15.0f %12.2f %10s%n", label, med, rows * 1000.0 / med, mb, ratio); - csv.printf("%s,%s,%d,%.0f,%.2f,%s%n", category, label, med, rows * 1000.0 / med, mb, ratio); - return size; - } - - private static long median(long[] times) { - long[] sorted = times.clone(); - Arrays.sort(sorted); - return sorted[sorted.length / 2]; - } - - private static FrameBlock loadLineitem(File f) throws Exception { - System.out.print("Loading " + f.getPath() + " ... "); - List rows = new ArrayList<>(); - try (BufferedReader br = new BufferedReader(new FileReader(f))) { - String line; - while ((line = br.readLine()) != null && rows.size() < MAX_ROWS) { - if (line.isEmpty()) continue; - if (line.endsWith("|")) line = line.substring(0, line.length() - 1); - rows.add(line.split("\\|", -1)); - } - } - String[][] data = rows.toArray(new String[0][]); - System.out.println(data.length + " rows loaded."); - return new FrameBlock(LINEITEM_SCHEMA, LINEITEM_NAMES, data); - } -} diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java index 4c7f2ccae2e..3e5fc17c907 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/ReadParquetTest.java @@ -20,17 +20,17 @@ package org.apache.sysds.test.functions.io.parquet; import java.io.File; +import java.io.IOException; import java.nio.file.Files; -import java.util.HashSet; import java.util.Map; -import java.util.Set; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.runtime.frame.data.FrameBlock; import org.apache.sysds.runtime.io.FrameReaderParquet; import org.apache.sysds.runtime.io.FrameReaderParquetParallel; import org.apache.sysds.runtime.io.FrameWriterParquet; -import org.apache.sysds.runtime.io.FrameWriterParquetParallel; import org.apache.sysds.test.functions.io.parquet.ParquetTestUtils.ParquetMetadataInfo; import org.apache.sysds.test.TestUtils; import org.junit.AfterClass; @@ -38,6 +38,9 @@ import org.junit.BeforeClass; import org.junit.Test; +/** + * Verifies the parquet frame readers against Spark's parquet reader on Spark-written files. + */ public class ReadParquetTest { // Generated once per test class with Spark's DataFrameWriter @@ -48,91 +51,32 @@ public class ReadParquetTest { public static void generateTestFiles() throws Exception { testFileDir = Files.createTempDirectory("systemds_parquet_public_test_files").toFile(); Map files = ParquetTestUtils.generatePublicTestFiles(testFileDir); - FILENAMES = new String[] { files.get("userdata1"), files.get("alltypes_plain"), files.get("all") }; + FILENAMES = new String[] {files.get("userdata1"), files.get("alltypes_plain"), files.get("all")}; } @AfterClass public static void cleanupTestFiles() { - File[] children = testFileDir.listFiles(); - if (children != null) - for (File f : children) - f.delete(); - testFileDir.delete(); - } - - @Test - public void testReadParquet() throws Exception { - for (String filename : FILENAMES) { - ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); - - FrameReaderParquet reader = new FrameReaderParquet(); - FrameBlock frame = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); - - Assert.assertEquals("Row count mismatch for " + filename, info.rlen, frame.getNumRows()); - Assert.assertEquals("Column count mismatch for " + filename, info.clen, frame.getNumColumns()); - } + deleteRecursive(testFileDir); } - @Test - public void testInt96ColumnsDecodedCorrectly() throws Exception { - assertColumnIsEpochMillis(FILENAMES[0], 0); // userdata1: registration_dttm - assertColumnIsEpochMillis(FILENAMES[1], 9); // alltypes_plain: timestamp_col - } - - private void assertColumnIsEpochMillis(String filename, int colIdx) throws Exception { - ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); - FrameReaderParquet reader = new FrameReaderParquet(); - FrameBlock frame = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); - int decoded = 0; - for (int r = 0; r < frame.getNumRows(); r++) { - Object val = frame.get(r, colIdx); - if (val == null) continue; - decoded++; - Assert.assertTrue( - "Expected Long (epoch millis) at row " + r + " col " + colIdx + ", got: " + val.getClass().getSimpleName(), - val instanceof Long - ); - } - Assert.assertTrue("No INT96 values were decoded in " + filename, decoded > 0); - } - - @Test - public void testNullHandling() throws Exception { - File temp = Files.createTempFile("systemds_null_parquet", ".parquet").toFile(); - try { - ValueType[] schema = { ValueType.STRING, ValueType.STRING }; - String[] names = { "a", "b" }; - FrameBlock original = new FrameBlock(schema, names, - new String[][] { { "x", "y" }, { null, null }, { "p", "q" } }); - - new FrameWriterParquet().writeFrameToHDFS(original, temp.getPath(), 3, 2); - - FrameBlock result = new FrameReaderParquet() - .readFrameFromHDFS(temp.getPath(), schema, names, 3, 2); - - Assert.assertNotNull("Row 0 col 0 should be non-null", result.get(0, 0)); - Assert.assertNotNull("Row 0 col 1 should be non-null", result.get(0, 1)); - Assert.assertNull("Row 1 col 0 should be null", result.get(1, 0)); - Assert.assertNull("Row 1 col 1 should be null", result.get(1, 1)); - Assert.assertNotNull("Row 2 col 0 should be non-null", result.get(2, 0)); - Assert.assertNotNull("Row 2 col 1 should be non-null", result.get(2, 1)); - } finally { - temp.delete(); - } - + private static void deleteRecursive(File f) { + File[] children = f.listFiles(); + if(children != null) + for(File c : children) + deleteRecursive(c); + f.delete(); } @Test - public void testColumnReaderMatchesLegacy() throws Exception { - for (String filename : FILENAMES) { + public void testReadMatchesSpark() throws Exception { + for(String filename : FILENAMES) { ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); - FrameBlock legacy = new FrameReaderParquetLegacy() - .readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); - FrameBlock current = new FrameReaderParquet() - .readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + FrameBlock expected = ParquetTestUtils.sparkReadAsFrame(filename, info.schema, info.names); + FrameBlock actual = new FrameReaderParquet().readFrameFromHDFS(filename, info.schema, info.names, info.rlen, + info.clen); - TestUtils.compareFrames(legacy, current, false); + TestUtils.compareFrames(expected, actual, false); } } @@ -141,58 +85,95 @@ public void testColumnSubsetProjection() throws Exception { // request a reordered subset of columns (d, a, c; skip b) File temp = Files.createTempFile("systemds_subset_parquet", ".parquet").toFile(); try { - ValueType[] fullSchema = { ValueType.INT64, ValueType.STRING, ValueType.FP64, ValueType.BOOLEAN }; - String[] fullNames = { "a", "b", "c", "d" }; - FrameBlock original = new FrameBlock(fullSchema, fullNames, new String[][] { - { "10", "x", "1.5", "true" }, - { "20", "y", "2.5", "false" }, - { "30", "z", "3.5", "true" } - }); + ValueType[] fullSchema = {ValueType.INT64, ValueType.STRING, ValueType.FP64, ValueType.BOOLEAN}; + String[] fullNames = {"a", "b", "c", "d"}; + FrameBlock original = new FrameBlock(fullSchema, fullNames, + new String[][] {{"10", "x", "1.5", "true"}, {"20", "y", "2.5", "false"}, {"30", "z", "3.5", "true"}}); new FrameWriterParquet().writeFrameToHDFS(original, temp.getPath(), 3, 4); - ValueType[] subSchema = { ValueType.BOOLEAN, ValueType.INT64, ValueType.FP64 }; - String[] subNames = { "d", "a", "c" }; + ValueType[] subSchema = {ValueType.BOOLEAN, ValueType.INT64, ValueType.FP64}; + String[] subNames = {"d", "a", "c"}; - FrameBlock legacy = new FrameReaderParquetLegacy() - .readFrameFromHDFS(temp.getPath(), subSchema, subNames, 3, 3); - FrameBlock current = new FrameReaderParquet() - .readFrameFromHDFS(temp.getPath(), subSchema, subNames, 3, 3); + FrameBlock expected = ParquetTestUtils.sparkReadAsFrame(temp.getPath(), subSchema, subNames); + FrameBlock actual = new FrameReaderParquet().readFrameFromHDFS(temp.getPath(), subSchema, subNames, 3, 3); - TestUtils.compareFrames(legacy, current, false); - - Assert.assertEquals(true, current.get(0, 0)); // d - Assert.assertEquals(10L, current.get(0, 1)); // a - Assert.assertEquals(3.5, ((Number) current.get(2, 2)).doubleValue(), 0.0); // c - } finally { + TestUtils.compareFrames(expected, actual, false); + } + finally { temp.delete(); } } + @Test + public void testInt96ColumnRejectedOthersReadable() throws Exception { + String file = ParquetTestUtils.writeInt96TimestampFile(testFileDir); + + ValueType[] fullSchema = {ValueType.INT32, ValueType.INT64, ValueType.STRING}; + String[] fullNames = {"id", "ts", "name"}; + try { + new FrameReaderParquet().readFrameFromHDFS(file, fullSchema, fullNames, 3, 3); + Assert.fail("Expected rejection of the INT96 column"); + } + catch(IOException e) { + Assert.assertTrue(e.getMessage(), e.getMessage().contains("INT96")); + } + + ValueType[] subSchema = {ValueType.INT32, ValueType.STRING}; + String[] subNames = {"id", "name"}; + FrameBlock expected = ParquetTestUtils.sparkReadAsFrame(file, subSchema, subNames); + FrameBlock actual = new FrameReaderParquet().readFrameFromHDFS(file, subSchema, subNames, 3, 2); + TestUtils.compareFrames(expected, actual, false); + } + @Test public void testEmptyFile() throws Exception { File temp = Files.createTempFile("systemds_empty_parquet", ".parquet").toFile(); try { - ValueType[] schema = { ValueType.INT64, ValueType.STRING }; - String[] names = { "a", "b" }; + ValueType[] schema = {ValueType.INT64, ValueType.STRING}; + String[] names = {"a", "b"}; FrameBlock empty = new FrameBlock(schema, names, new String[0][]); new FrameWriterParquet().writeFrameToHDFS(empty, temp.getPath(), 0, 2); - FrameBlock legacy = new FrameReaderParquetLegacy() - .readFrameFromHDFS(temp.getPath(), schema, names, 0, 2); - FrameBlock current = new FrameReaderParquet() - .readFrameFromHDFS(temp.getPath(), schema, names, 0, 2); + FrameBlock result = new FrameReaderParquet().readFrameFromHDFS(temp.getPath(), schema, names, 0, 2); - Assert.assertEquals("Empty file should yield 0 rows (legacy)", 0, legacy.getNumRows()); - Assert.assertEquals("Empty file should yield 0 rows (column API)", 0, current.getNumRows()); - Assert.assertEquals(2, current.getNumColumns()); - } finally { + Assert.assertEquals("Empty file should yield 0 rows", 0, result.getNumRows()); + Assert.assertEquals(2, result.getNumColumns()); + } + finally { temp.delete(); } } + @Test + public void testReadSparkOutputDirectory() throws Exception { + // raw Spark output layout: a directory with _SUCCESS marker, .crc files and uuid part names; + // repartition shuffles rows across the part files, so values are verified keyed by id + File dir = new File(testFileDir, "spark_dir_out"); + ValueType[] schema = {ValueType.INT64, ValueType.FP64, ValueType.STRING}; + String[] names = {"id", "val", "name"}; + SparkSession spark = ParquetTestUtils.sparkSession(); + spark.range(100).selectExpr("id", "id * 0.5d as val", "concat('r', id) as name").repartition(2).write() + .mode(SaveMode.Overwrite).parquet(dir.getPath()); + Assert.assertTrue(new File(dir, "_SUCCESS").exists()); + + FrameBlock serial = new FrameReaderParquet().readFrameFromHDFS(dir.getPath(), schema, names, 100, 3); + Assert.assertEquals(100, serial.getNumRows()); + boolean[] seen = new boolean[100]; + for(int r = 0; r < 100; r++) { + int id = ((Long) serial.get(r, 0)).intValue(); + Assert.assertFalse("duplicate id " + id, seen[id]); + seen[id] = true; + Assert.assertEquals(id * 0.5, (Double) serial.get(r, 1), 0); + Assert.assertEquals("r" + id, serial.get(r, 2)); + } + + FrameBlock parallel = new FrameReaderParquetParallel().readFrameFromHDFS(dir.getPath(), schema, names, 100, 3); + TestUtils.compareFrames(serial, parallel, false); + } + @Test public void testParallelReaderMatchesSequential() throws Exception { - for (String filename : FILENAMES) { + for(String filename : FILENAMES) { ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); FrameReaderParquet sequential = new FrameReaderParquet(); @@ -204,85 +185,4 @@ public void testParallelReaderMatchesSequential() throws Exception { TestUtils.compareFrames(expected, actual, false); } } - - @Test - public void testParallelReaderMultiFileOffsets() throws Exception { - // each file must use its own row range, else the parallel reader overwrites/loses rows - File tempDir = Files.createTempDirectory("systemds_parallel_parquet").toFile(); - try { - ValueType[] schema = { ValueType.STRING, ValueType.INT32 }; - String[] names = { "label", "value" }; - - FrameBlock block1 = new FrameBlock(schema, names, - new String[][] { { "a", "1" }, { "b", "2" }, { "c", "3" } }); - FrameBlock block2 = new FrameBlock(schema, names, - new String[][] { { "d", "4" }, { "e", "5" }, { "f", "6" } }); - - String path1 = tempDir + "/part-0.parquet"; - String path2 = tempDir + "/part-1.parquet"; - FrameWriterParquet writer = new FrameWriterParquet(); - writer.writeFrameToHDFS(block1, path1, 3, 2); - writer.writeFrameToHDFS(block2, path2, 3, 2); - - FrameReaderParquet seq = new FrameReaderParquet(); - Set expectedLabels = new HashSet<>(); - String[] parts = { path1, path2 }; - for (String p : parts) { - FrameBlock fb = seq.readFrameFromHDFS(p, schema, names, 3, 2); - for (int r = 0; r < fb.getNumRows(); r++) - expectedLabels.add((String) fb.get(r, 0)); - } - - FrameReaderParquetParallel parallel = new FrameReaderParquetParallel(); - FrameBlock result = parallel.readFrameFromHDFS(tempDir.toString(), schema, names, 6, 2); - - Assert.assertEquals("Expected 6 total rows", 6, result.getNumRows()); - - Set actualLabels = new HashSet<>(); - for (int r = 0; r < result.getNumRows(); r++) { - Object label = result.get(r, 0); - Assert.assertNotNull("Row " + r + " is null, row-offset bug suspected", label); - actualLabels.add((String) label); - } - Assert.assertEquals("Parallel result does not match sequential ground truth", expectedLabels, actualLabels); - } finally { - for (File f : tempDir.listFiles()) - f.delete(); - tempDir.delete(); - } - } - - @Test - public void testParallelWriterRoundTrip() throws Exception { - File tempDir = Files.createTempDirectory("systemds_parallel_write").toFile(); - try { - ValueType[] schema = { ValueType.INT64, ValueType.STRING, ValueType.FP64 }; - String[] names = { "id", "name", "val" }; - String[][] data = new String[20][]; - for (int i = 0; i < 20; i++) - data[i] = new String[] { String.valueOf(i), "row" + i, String.valueOf(i + 0.5) }; - FrameBlock original = new FrameBlock(schema, names, data); - - FrameWriterParquetParallel writer = new FrameWriterParquetParallel(); - writer.setForcedParallel(true); - writer.writeFrameToHDFS(original, tempDir.getPath(), 20, 3); - - FrameBlock result = new FrameReaderParquetParallel() - .readFrameFromHDFS(tempDir.getPath(), schema, names, 20, 3); - - Assert.assertEquals("Row count mismatch after parallel write", 20, result.getNumRows()); - // Rows may be out of order in parallel reads, validate by comparing tuples, not row positions - Set expected = new HashSet<>(); - for (int i = 0; i < 20; i++) - expected.add(i + "|row" + i + "|" + (i + 0.5)); - Set actual = new HashSet<>(); - for (int r = 0; r < result.getNumRows(); r++) - actual.add(result.get(r, 0) + "|" + result.get(r, 1) + "|" + result.get(r, 2)); - Assert.assertEquals("Parallel-written data does not round-trip", expected, actual); - } finally { - for (File f : tempDir.listFiles()) - f.delete(); - tempDir.delete(); - } - } } diff --git a/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java b/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java index e8a6c5b3599..46434da26a2 100644 --- a/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java +++ b/src/test/java/org/apache/sysds/test/functions/io/parquet/WriteParquetTest.java @@ -21,27 +21,42 @@ import java.io.File; import java.nio.file.Files; +import java.util.Arrays; import java.util.Map; -import org.apache.sysds.test.TestUtils; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; import org.apache.sysds.common.Types.ValueType; import org.apache.sysds.runtime.frame.data.FrameBlock; import org.apache.sysds.runtime.io.FrameReaderParquet; import org.apache.sysds.runtime.io.FrameReaderParquetParallel; import org.apache.sysds.runtime.io.FrameWriterParquet; import org.apache.sysds.runtime.io.FrameWriterParquetParallel; +import org.apache.sysds.test.TestUtils; import org.apache.sysds.test.functions.io.parquet.ParquetTestUtils.ParquetMetadataInfo; import org.junit.After; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; +/** + * Verifies the parquet frame writers against Spark's parquet reader. + * + * Column 0 carries the row index so Spark reads of multi-file outputs can be compared order-independently (sorted by + * id), since engines do not guarantee row order across files. + */ public class WriteParquetTest { - private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + "/systemds_write_parquet_test.parquet"; - private static final String TEMP_PAR_PATH = System.getProperty("java.io.tmpdir") + "/systemds_write_parquet_test_par"; + private static final String TEMP_FILE = System.getProperty("java.io.tmpdir") + + "/systemds_write_parquet_test.parquet"; + private static final String TEMP_PAR_PATH = System.getProperty("java.io.tmpdir") + + "/systemds_write_parquet_test_par"; + + private static final ValueType[] SCHEMA = {ValueType.INT64, ValueType.STRING, ValueType.FP64, ValueType.BOOLEAN, + ValueType.INT32, ValueType.FP32}; + private static final long SEED = 4669201; - // See ParquetTestUtils.generatePublicTestFiles(): these are generated with Spark's DataFrameWriter + // See ParquetTestUtils.generatePublicTestFiles(): these are generated with Spark's DataFrameWriter private static File testFileDir; private static String[] PUBLIC_FILES; @@ -49,14 +64,14 @@ public class WriteParquetTest { public static void generateTestFiles() throws Exception { testFileDir = Files.createTempDirectory("systemds_parquet_public_test_files").toFile(); Map files = ParquetTestUtils.generatePublicTestFiles(testFileDir); - PUBLIC_FILES = new String[] { files.get("userdata1"), files.get("alltypes_plain"), files.get("all") }; + PUBLIC_FILES = new String[] {files.get("userdata1"), files.get("alltypes_plain"), files.get("all")}; } @AfterClass public static void cleanupTestFiles() { File[] children = testFileDir.listFiles(); - if (children != null) - for (File f : children) + if(children != null) + for(File f : children) f.delete(); testFileDir.delete(); } @@ -68,113 +83,98 @@ public void cleanup() { } private static void deleteRecursive(File f) { - if (f.isDirectory()) - for (File c : f.listFiles()) deleteRecursive(c); + if(f.isDirectory()) + for(File c : f.listFiles()) + deleteRecursive(c); f.delete(); } @Test - public void testRoundtripPublicFiles() throws Exception { - for (String filename : PUBLIC_FILES) { - ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + public void testSparkReadsMultiPartFiles() throws Exception { + FrameBlock original = indexedRandomFrame(60); + writeTwoParts(original); - FrameReaderParquet reader = new FrameReaderParquet(); - FrameBlock original = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + FrameBlock result = sparkReadSorted(TEMP_PAR_PATH, original.getColumnNames()); + TestUtils.compareFrames(original, result, false); + } - FrameWriterParquet writer = new FrameWriterParquet(); - writer.writeFrameToHDFS(original, TEMP_FILE, original.getNumRows(), original.getNumColumns()); + @Test + public void testMultiPartFileRoundtrip() throws Exception { + FrameBlock original = indexedRandomFrame(60); + writeTwoParts(original); - FrameBlock result = reader.readFrameFromHDFS(TEMP_FILE, info.schema, info.names, info.rlen, info.clen); + FrameBlock result = new FrameReaderParquetParallel().readFrameFromHDFS(TEMP_PAR_PATH, SCHEMA, + original.getColumnNames(), 60, SCHEMA.length); - TestUtils.compareFrames(original, result, false); - } + TestUtils.compareFrames(original, result, false); } @Test - public void testMultiPartFileRoundtrip() throws Exception { - // Create two parquet part files and verify that the parallel reader - // reads both files correctly and combines them into the expected result. - ValueType[] schema = { - ValueType.STRING, - ValueType.INT32, - ValueType.INT64, - ValueType.FP32, - ValueType.FP64, - ValueType.BOOLEAN - }; - String[] names = { "name", "age", "id", "score", "ratio", "active" }; - String[][] data = { - { "Alice", "30", "1000", "1.5", "0.75", "true" }, - { "Bob", "25", "2000", "2.5", "0.50", "false" }, - { "Carol", "40", "3000", "3.5", "0.25", "true" }, - { "Dave", "35", "4000", "4.5", "0.10", "false" }, - { "Eve", "28", "5000", "5.5", "0.90", "true" }, - { "Frank", "45", "6000", "6.5", "0.60", "false" } - }; - FrameBlock original = new FrameBlock(schema, names, data); - - new File(TEMP_PAR_PATH).mkdir(); - FrameWriterParquet writer = new FrameWriterParquet(); - writer.writeFrameToHDFS(original.slice(0, 2), TEMP_PAR_PATH + "/part-0.parquet", 3, schema.length); - writer.writeFrameToHDFS(original.slice(3, 5), TEMP_PAR_PATH + "/part-1.parquet", 3, schema.length); + public void testSequentialReaderMultiPartFileRoundtrip() throws Exception { + // the serial reader must also expand a directory into its part files, not just the parallel one + FrameBlock original = indexedRandomFrame(60); + writeTwoParts(original); - FrameBlock result = new FrameReaderParquetParallel() - .readFrameFromHDFS(TEMP_PAR_PATH, schema, names, 6, schema.length); + FrameBlock result = new FrameReaderParquet().readFrameFromHDFS(TEMP_PAR_PATH, SCHEMA, original.getColumnNames(), + 60, SCHEMA.length); TestUtils.compareFrames(original, result, false); } @Test - public void testParallelRoundtrip() throws Exception { - ValueType[] schema = { - ValueType.STRING, - ValueType.INT32, - ValueType.INT64, - ValueType.FP32, - ValueType.FP64, - ValueType.BOOLEAN - }; - String[] names = { "name", "age", "id", "score", "ratio", "active" }; - String[][] data = { - { "Alice", "30", "1000", "1.5", "0.75", "true" }, - { "Bob", "25", "2000", "2.5", "0.50", "false" }, - { "Carol", "40", "3000", "3.5", "0.25", "true" } - }; - FrameBlock original = new FrameBlock(schema, names, data); - - new FrameWriterParquetParallel().writeFrameToHDFS(original, TEMP_PAR_PATH, original.getNumRows(), original.getNumColumns()); - FrameBlock result = new FrameReaderParquetParallel().readFrameFromHDFS(TEMP_PAR_PATH, schema, names, original.getNumRows(), original.getNumColumns()); + public void testForcedParallelWriterRoundTrip() throws Exception { + FrameBlock original = indexedRandomFrame(20); + + FrameWriterParquetParallel writer = new FrameWriterParquetParallel(); + writer.setForcedParallel(true); + writer.writeFrameToHDFS(original, TEMP_PAR_PATH, 20, SCHEMA.length); + + FrameBlock result = new FrameReaderParquetParallel().readFrameFromHDFS(TEMP_PAR_PATH, SCHEMA, + original.getColumnNames(), 20, SCHEMA.length); TestUtils.compareFrames(original, result, false); } @Test - public void testRoundtrip() throws Exception { - ValueType[] schema = { - ValueType.STRING, - ValueType.INT32, - ValueType.INT64, - ValueType.FP32, - ValueType.FP64, - ValueType.BOOLEAN - }; - String[] names = { "name test", "age", "id", "score", "ratio", "active" }; - String[][] data = { - { "Alice", "30", "1000", "1.5", "0.75", "true" }, - { "Bob", "25", "2000", "2.5", "0.50", "false" }, - { "Carol", "40", "3000", "3.5", "0.25", "true" } - }; - - FrameBlock original = new FrameBlock(schema, names, data); - - // Write - FrameWriterParquet writer = new FrameWriterParquet(); - writer.writeFrameToHDFS(original, TEMP_FILE, original.getNumRows(), original.getNumColumns()); + public void testRoundtripPublicFiles() throws Exception { + for(String filename : PUBLIC_FILES) { + ParquetMetadataInfo info = ParquetTestUtils.inferMetadata(filename); + + FrameReaderParquet reader = new FrameReaderParquet(); + FrameBlock original = reader.readFrameFromHDFS(filename, info.schema, info.names, info.rlen, info.clen); + + FrameWriterParquet writer = new FrameWriterParquet(); + writer.writeFrameToHDFS(original, TEMP_FILE, original.getNumRows(), original.getNumColumns()); - // Read back - FrameReaderParquet reader = new FrameReaderParquet(); - FrameBlock result = reader.readFrameFromHDFS(TEMP_FILE, schema, names, original.getNumRows(), original.getNumColumns()); + FrameBlock result = reader.readFrameFromHDFS(TEMP_FILE, info.schema, info.names, info.rlen, info.clen); - TestUtils.compareFrames(original, result, false); + TestUtils.compareFrames(original, result, false); + + FrameBlock sparkResult = ParquetTestUtils.sparkReadAsFrame(TEMP_FILE, info.schema, info.names); + TestUtils.compareFrames(original, sparkResult, false); + } + } + + /** Random frame over all parquet-supported value types, with the row index in column 0. */ + private static FrameBlock indexedRandomFrame(int rows) { + FrameBlock fb = TestUtils.generateRandomFrameBlock(rows, SCHEMA, SEED); + for(int r = 0; r < rows; r++) + fb.set(r, 0, (long) r); + return fb; + } + + private static void writeTwoParts(FrameBlock fb) throws Exception { + int rows = fb.getNumRows(); + new File(TEMP_PAR_PATH).mkdir(); + FrameWriterParquet writer = new FrameWriterParquet(); + writer.writeFrameToHDFS(fb.slice(0, rows / 2 - 1), TEMP_PAR_PATH + "/part-0.parquet", rows / 2, SCHEMA.length); + writer.writeFrameToHDFS(fb.slice(rows / 2, rows - 1), TEMP_PAR_PATH + "/part-1.parquet", rows - rows / 2, + SCHEMA.length); + } + + private static FrameBlock sparkReadSorted(String path, String[] names) { + Dataset df = ParquetTestUtils.sparkSession().read().parquet(path) + .select(names[0], Arrays.copyOfRange(names, 1, names.length)).sort(names[0]); + return ParquetTestUtils.toFrameBlock(df.collectAsList(), SCHEMA, names); } }