@@ -393,3 +393,77 @@ def test_clamping_handles_one_dimensional_chunks():
393393
394394 assert _clamp_chunks ({"chunks" : (100 ,)}, 5 )["chunks" ] == (5 ,)
395395 assert _clamp_chunks ({}, 5 ) == {}
396+
397+
398+ # ---------------------------------------------------------------------------
399+ # read sizing
400+
401+
402+ def test_read_step_grows_past_a_pathological_source_chunk ():
403+ """A `(1, n_cols)` source must not be copied one row per read.
404+
405+ Forwarding the source's chunk height meant inheriting whatever the writer
406+ chose. On a network filesystem each read is a round-trip, so a row-chunked
407+ million-cell store spent the whole copy waiting on latency.
408+ """
409+ from adata .storage import TARGET_READ_BYTES , _chunk_step , _row_bytes
410+
411+ shape , chunks , dtype = (1_000_000 , 30_000 ), (1 , 30_000 ), np .dtype ("float32" )
412+ step = _chunk_step (shape , chunks , dtype )
413+
414+ assert step > 1 , "the source chunk height is no longer used verbatim"
415+ assert step * _row_bytes (shape , dtype ) >= TARGET_READ_BYTES // 2
416+
417+
418+ def test_read_step_stays_a_whole_number_of_source_chunks ():
419+ """Reading part of a chunk still costs decompressing all of it."""
420+ from adata .storage import _chunk_step
421+
422+ for chunk_rows in (1 , 7 , 100 , 65_536 ):
423+ step = _chunk_step ((10_000_000 ,), (chunk_rows ,), np .dtype ("int64" ))
424+ assert step % chunk_rows == 0
425+
426+
427+ def test_read_step_never_exceeds_the_dataset ():
428+ from adata .storage import _chunk_step
429+
430+ assert _chunk_step ((10 ,), (1 ,), np .dtype ("int64" )) == 10
431+ assert _chunk_step ((), None , np .dtype ("int64" )) == 1
432+ assert _chunk_step ((0 ,), None , np .dtype ("int64" )) == 1
433+
434+
435+ def test_read_step_bounds_peak_memory ():
436+ """A wide unchunked source used to read 1024 rows however wide they were."""
437+ from adata .storage import TARGET_READ_BYTES , _chunk_step , _row_bytes
438+
439+ cases = [
440+ ((1_000_000 , 30_000 ), None , np .dtype ("float32" )),
441+ ((1_000_000 , 50 ), (1 , 50 ), np .dtype ("float32" )),
442+ ((200_000_000 ,), (65_536 ,), np .dtype ("int64" )),
443+ ]
444+ for shape , chunks , dtype in cases :
445+ step = _chunk_step (shape , chunks , dtype )
446+ assert step * _row_bytes (shape , dtype ) <= 2 * TARGET_READ_BYTES
447+
448+
449+ def test_row_bytes_does_not_trust_a_vlen_itemsize ():
450+ """h5py reports itemsize 8 for vlen str -- that is the pointer, not the text."""
451+ import h5py
452+
453+ from adata .storage import VLEN_ELEMENT_BYTES , _row_bytes
454+
455+ assert _row_bytes ((10 ,), h5py .string_dtype (encoding = "utf-8" )) == VLEN_ELEMENT_BYTES
456+ assert _row_bytes ((10 ,), np .dtype ("O" )) == VLEN_ELEMENT_BYTES
457+ assert _row_bytes ((10 , 4 ), np .dtype ("float32" )) == 16
458+
459+
460+ def test_copy_dataset_of_a_row_chunked_source_round_trips (new_store ):
461+ """The larger read step must not change what lands on disk."""
462+ path , opener = new_store ()
463+ with opener ("a" ) as root :
464+ from adata .storage import create_dataset
465+
466+ values = np .arange (400 , dtype = "float32" ).reshape (100 , 4 )
467+ create_dataset (root ["uns" ], "src" , data = values , chunks = (1 , 4 ))
468+ copy_dataset (root ["uns" ]["src" ], root ["uns" ], "dst" )
469+ assert np .array_equal (root ["uns" ]["dst" ][...], values )
0 commit comments