Repository: h5py/h5py (python)

Issue:
Add option to never write fill value via the high-level dataset creation interface
This is not a bug report.

Since this is my first issue here, I just want to take this chance to thank all the developers in `h5py` for providing this convenient Python wrapper for hdf5! 

#### Background
For parallel writing to chunked storage, the chunks are allocated when the dataset is created and in `h5py` [it enforces the time of writing fill value on allocation](https://github.com/h5py/h5py/blob/master/h5py/_hl/filters.py#L262). From the comment there I guess it is to ensure all values have been properly initialised to avoid reading uninitialised memory. However, this is unnecessary when we know we are definitely going to overwrite all the fill values in our whole dataset by our actual data. 

#### Current limitation
I found out [you can pass your dataset creation property list using the keyword `dcpl`](https://github.com/h5py/h5py/blob/master/h5py/_hl/dataset.py#L39) from `create_dataset`, so potentially I can use the `set_fill_time` method to change it to `FILL_TIME_NEVER` for my own dcpl, however it will be [overwritten by `FILL_TIME_ALLOC`](https://github.com/h5py/h5py/blob/master/h5py/_hl/filters.py#L262) later. Another route is to bypass the high-level interface entirely by using the exposed C API, but I will miss all the validations provided to other settings.

#### Suggestion
I would like to suggest exposing the option of never writing fill values via the high-level `create_dataset`/`require_dataset` interface. One way of doing it is to provide a parameter in those methods, such as something like `write_fill` with options `"alloc"`/`"never"`/`"ifset"` ([the current options in hdf5](https://docs.hdfgroup.org/hdf5/v1_14/_h5_dpublic_8h.html#aa39293626c4e68dd28b06c0dc84bde4a)), default it to `"alloc"` and this will keep the original behaviour. By consciously passing `write_fill="never"`, the user is aware of and responsible for this.  

Another (less obvious) way is to check the setting of `dcpl` to see what fill time option is set, and if equates to the enum value of `H5D_FILL_TIME_NEVER`, just keep it.

#### Potential action
[Setting the fill time to `H5D_FILL_TIME_NEVER` can have significant performance improvement for parallel writing to chunked storage](https://www.hdfgroup.org/wp-content/uploads/2022/05/2022_ECP-Parallel-HDF5-Performance-Tuning.pdf). I hope this can benefit users who know they will definitely overwrite the fill values by their data. I am more than happy to contribute to this if `h5py` developers are happy about this idea and this won't cause significant safety and security issues.

Thank you.

Useful link:
[Storage allocation and fill summary](https://docs.hdfgroup.org/hdf5/v1_14/_h5_d__u_g.html#subsubsec_dataset_allocation_store)

Pull request:
Expose fill time property via the high-level dataset creation interface
This PR exposes the fill time property via the high-level dataset creation interface. This is my first PR to `h5py` so feel free to let me know what I could have missed. Thank you.

For the motivation of this PR, please see #2459.

Ready for review.

Upon merging, this closes #2459.

Patch:
diff --git a/docs/high/group.rst b/docs/high/group.rst
index e6d8885f..7b222a06 100644
--- a/docs/high/group.rst
+++ b/docs/high/group.rst
@@ -382,6 +382,15 @@ Reference
         :keyword fillvalue: This value will be used when reading
                             uninitialized parts of the dataset.
 
+        :keyword fill_time: Control when to write the fill value. One of the
+            following choices: `alloc`, write fill value before writing
+            application data values or when the dataset is created; `never`,
+            never write fill value; `ifset`, write fill value if it is defined.
+            Default to `ifset`, which is the default of HDF5 library. If the
+            whole dataset is going to be written by the application, setting
+            this to `never` can avoid unnecessary writing of fill value and
+            potentially improve performance.
+
         :keyword track_times: Enable dataset creation timestamps (**T**/F).
 
         :keyword track_order: Track attribute creation order if
diff --git a/h5py/_hl/dataset.py b/h5py/_hl/dataset.py
index c2a65346..1406cefb 100644
--- a/h5py/_hl/dataset.py
+++ b/h5py/_hl/dataset.py
@@ -38,7 +38,8 @@ def make_new_dset(parent, shape=None, dtype=None, data=None, name=None,
                   fillvalue=None, scaleoffset=None, track_times=False,
                   external=None, track_order=None, dcpl=None, dapl=None,
                   efile_prefix=None, virtual_prefix=None, allow_unknown_filter=False,
-                  rdcc_nslots=None, rdcc_nbytes=None, rdcc_w0=None):
+                  rdcc_nslots=None, rdcc_nbytes=None, rdcc_w0=None, *,
+                  fill_time=None):
     """ Return a new low-level dataset identifier """
 
     # Convert data to a C-contiguous ndarray
@@ -104,7 +105,8 @@ def make_new_dset(parent, shape=None, dtype=None, data=None, name=None,
     dcpl = filters.fill_dcpl(
         dcpl or h5p.create(h5p.DATASET_CREATE), shape, dtype,
         chunks, compression, compression_opts, shuffle, fletcher32,
-        maxshape, scaleoffset, external, allow_unknown_filter)
+        maxshape, scaleoffset, external, allow_unknown_filter,
+        fill_time=fill_time)
 
     if fillvalue is not None:
         # prepare string-type dtypes for fillvalue
diff --git a/h5py/_hl/filters.py b/h5py/_hl/filters.py
index 493e9e2f..ee89e232 100644
--- a/h5py/_hl/filters.py
+++ b/h5py/_hl/filters.py
@@ -52,6 +52,10 @@ _COMP_FILTERS = {'gzip': h5z.FILTER_DEFLATE,
                 'shuffle': h5z.FILTER_SHUFFLE,
                 'fletcher32': h5z.FILTER_FLETCHER32,
                 'scaleoffset': h5z.FILTER_SCALEOFFSET }
+_FILL_TIME_ENUM = {'alloc': h5d.FILL_TIME_ALLOC,
+                   'never': h5d.FILL_TIME_NEVER,
+                   'ifset': h5d.FILL_TIME_IFSET,
+                   }
 
 DEFAULT_GZIP = 4
 DEFAULT_SZIP = ('nn', 8)
@@ -146,7 +150,7 @@ class Gzip(FilterRefBase):
 
 def fill_dcpl(plist, shape, dtype, chunks, compression, compression_opts,
               shuffle, fletcher32, maxshape, scaleoffset, external,
-              allow_unknown_filter=False):
+              allow_unknown_filter=False, *, fill_time=None):
     """ Generate a dataset creation property list.
 
     Undocumented and subject to change without warning.
@@ -259,7 +263,14 @@ def fill_dcpl(plist, shape, dtype, chunks, compression, compression_opts,
 
     if chunks is not None:
         plist.set_chunk(chunks)
-        plist.set_fill_time(h5d.FILL_TIME_ALLOC)  # prevent resize glitch
+
+    if fill_time is not None:
+        if (ft := _FILL_TIME_ENUM.get(fill_time)) is not None:
+            plist.set_fill_time(ft)
+        else:
+            msg = ("fill_time must be one of the following choices: 'alloc', "
+                   f"'never' or 'ifset', but it is {fill_time}.")
+            raise ValueError(msg)
 
     # scale-offset must come before shuffle and compression
     if scaleoffset is not None:
diff --git a/news/expose_fill_time.rst b/news/expose_fill_time.rst
new file mode 100644
index 00000000..3db73c2f
--- /dev/null
+++ b/news/expose_fill_time.rst
@@ -0,0 +1,13 @@
+Breaking Changes and Deprecations
+------------
+
+* Fill time for chunked storage was set to ``h5d.FILL_TIME_ALLOC``. Now this
+  is handled by HDF5 library where the default is ``h5d.FILL_TIME_IFSET``
+  (equivalent to ``fill_time='ifset'``). Please use ``fill_time='alloc'`` if
+  the behaviour in previous releases is wanted.
+
+Exposing HDF5 functions
+-----------------------
+
+* Expose fill time option in dataset creation property list via the
+  ``fill_time`` parameter in ``create_dataset``.


Test patch:
diff --git a/h5py/tests/test_dataset.py b/h5py/tests/test_dataset.py
index 5f102385..1e9bac3a 100644
--- a/h5py/tests/test_dataset.py
+++ b/h5py/tests/test_dataset.py
@@ -476,6 +476,90 @@ class TestCreateFillvalue(BaseDataset):
                     dtype=[('a', 'i'), ('b', 'f')], fillvalue=42)
 
 
+class TestFillTime(BaseDataset):
+
+    """
+        Feature: Datasets created with specified fill time property
+    """
+
+    def test_fill_time_default(self):
+        """ Fill time default to IFSET """
+        dset = self.f.create_dataset('foo', (10,), fillvalue=4.0)
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_IFSET)
+        self.assertEqual(dset[0], 4.0)
+        self.assertEqual(dset[7], 4.0)
+
+    @ut.skipIf('gzip' not in h5py.filters.encode, "DEFLATE is not installed")
+    def test_compressed_default(self):
+        """ Fill time is IFSET for compressed dataset (chunked) """
+        dset = self.f.create_dataset('foo', (10,), compression='gzip',
+                                     fillvalue=4.0)
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_IFSET)
+        self.assertEqual(dset[0], 4.0)
+        self.assertEqual(dset[7], 4.0)
+
+    def test_fill_time_never(self):
+        """ Fill time set to NEVER """
+        dset = self.f.create_dataset('foo', (10,), fillvalue=4.0,
+                                     fill_time='never')
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_NEVER)
+        # should not be equal to the explicitly set fillvalue
+        self.assertNotEqual(dset[0], 4.0)
+        self.assertNotEqual(dset[7], 4.0)
+
+    def test_fill_time_alloc(self):
+        """ Fill time explicitly set to ALLOC """
+        dset = self.f.create_dataset('foo', (10,), fillvalue=4.0,
+                                     fill_time='alloc')
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_ALLOC)
+
+    def test_fill_time_ifset(self):
+        """ Fill time explicitly set to IFSET """
+        dset = self.f.create_dataset('foo', (10,), chunks=(2,), fillvalue=4.0,
+                                     fill_time='ifset')
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_IFSET)
+
+    def test_invalid_fill_time(self):
+        """ Choice of fill_time is 'alloc', 'never', 'ifset' """
+        with self.assertRaises(ValueError):
+            dset = self.f.create_dataset('foo', (10,), fill_time='fill_bad')
+
+    def test_non_str_fill_time(self):
+        """ fill_time must be a string """
+        with self.assertRaises(ValueError):
+            dset = self.f.create_dataset('foo', (10,), fill_time=2)
+
+    def test_resize_chunk_fill_time_default(self):
+        """ The resize dataset will be filled (by default fill value 0) """
+        dset = self.f.create_dataset('foo', (50, ), maxshape=(100, ),
+                                     chunks=(5, ))
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_IFSET)
+
+        assert np.isclose(dset[:], 0.0).all()
+
+        dset.resize((100, ))
+        assert np.isclose(dset[:], 0.0).all()
+
+    def test_resize_chunk_fill_time_never(self):
+        """ The resize dataset won't be filled """
+        dset = self.f.create_dataset('foo', (50, ), maxshape=(100, ),
+                                     fillvalue=4.0, fill_time='never',
+                                     chunks=(5, ))
+        plist = dset.id.get_create_plist()
+        self.assertEqual(plist.get_fill_time(), h5py.h5d.FILL_TIME_NEVER)
+
+        assert not np.isclose(dset[:], 4.0).any()
+
+        dset.resize((100, ))
+        assert not np.isclose(dset[:], 4.0).any()
+
+
 @pytest.mark.parametrize('dt,expected', [
     (int, 0),
     (np.int32, 0),