Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Old mmap->lazyio, add new mmap #79

Merged
merged 16 commits into from
May 26, 2022
Merged

Old mmap->lazyio, add new mmap #79

merged 16 commits into from
May 26, 2022

Conversation

timholy
Copy link
Contributor

@timholy timholy commented May 23, 2022

This PR reworks the handling of "mmapped" images, preserving the
previous implementation and making it accessible with lazyio=true.
The main motivation is to take advantage of the OS-provided buffering
of mmapped data, so that elementwise access patterns become much
more efficient, particularly for 3d images. (In some simple demos,
by multiple orders of magnitude.)

I went with a "name swap" because calling the previous implementation
"mmap" is a misnomer: it is a useful form of lazy-loading, but
there was no use of "true" mmapping in the sense of Mmap.mmap.

This also introduces the ability to open a mmapped TIFF with r+
permissions and thus write (simply by setting values) as well as read.

Here's a quick demo of the performance improvement:

julia> using TiffImages, ImageCore

julia> img0 = rand(Gray{N0f16}, 1000, 1000, 40);

julia> TiffImages.save("/tmp/stack.tif", img0);

julia> img1 = TiffImages.load("/tmp/stack.tif");

julia> img2 = TiffImages.load("/tmp/stack.tif"; mmap=true);

julia> img3 = TiffImages.load("/tmp/stack.tif"; lazyio=true);

julia> using BenchmarkTools

julia> @btime $img1[1,1,1] + $img1[1,1,2]
  2.839 ns (0 allocations: 0 bytes)
Gray{N0f16}(0.01758)

julia> @btime $img2[1,1,1] + $img2[1,1,2]
  7.432 ns (0 allocations: 0 bytes)
Gray{N0f16}(0.01758)

julia> @btime $img3[1,1,1] + $img3[1,1,2]
  231.848 μs (21 allocations: 496 bytes)
Gray{N0f16}(0.01758)

In this particular case it's an improvement of more than 4 orders of magnitude. The reason is that by switching slice planes, the lazyio=true version has to re-read the active slice each time.

What this should enable is applications like https://imagej.net/plugins/bdv/ in which the slice visualization plane may be chosen arbitrarily, without forcing users to write their data in 3d chunks using HDF5 or similar formats.

Closes JuliaArrays/MappedArrays.jl#49

This PR reworks the handling of "mmapped" images, preserving the
previous implementation and making it accessible with `lazyio=true`.
The main motivation is to take advantage of the OS-provided buffering
of mmapped data, so that elementwise access patterns become much
more efficient, particularly for 3d images. (In some simple demos,
by multiple orders of magnitude.)

I went with a "name swap" because calling the previous implementation
"mmap" is a misnomer: it is a useful form of lazy-loading, but
there was no use of "true" mmapping in the sense of `Mmap.mmap`.

This also introduces the ability to open a mmapped TIFF with `r+`
permissions and thus write (simply by setting values) as well as read.
@timholy
Copy link
Contributor Author

timholy commented May 23, 2022

I was going to use lazy=true, but it turns out that ImageIO now uses that in the sense of "lazy modules" and here I want it to mean "lazy data access".

Also, you'll note I did a minor version bump. There are enough behavioral changes:

  • now 2d TIFFs are returns as AbstractMatrix subtypes rather than being 3D with 3rd dimension 1
  • attempting to set values throws an error unless the file has been opened with read/write permissions
  • transpose is performed lazily rather than eagerly
  • the new implementation is more restrictive, and the old one is used as a fallback (with a warning)

that I was concerned that this is more than an "internal detail" and will require users to vet this more carefully.

@codecov-commenter
Copy link

codecov-commenter commented May 23, 2022

Codecov Report

Merging #79 (8abcb3d) into master (facdf3d) will increase coverage by 0.34%.
The diff coverage is 94.52%.

@@            Coverage Diff             @@
##           master      #79      +/-   ##
==========================================
+ Coverage   90.69%   91.04%   +0.34%     
==========================================
  Files          12       13       +1     
  Lines         688      748      +60     
==========================================
+ Hits          624      681      +57     
- Misses         64       67       +3     
Impacted Files Coverage Δ
src/types/lazy.jl 93.44% <ø> (ø)
src/utils.jl 72.30% <57.14%> (-1.84%) ⬇️
src/ifds.jl 97.31% <85.71%> (-0.62%) ⬇️
src/TiffImages.jl 80.00% <100.00%> (+8.57%) ⬆️
src/files.jl 80.64% <100.00%> (-1.33%) ⬇️
src/load.jl 98.00% <100.00%> (+0.17%) ⬆️
src/types/dense.jl 94.44% <100.00%> (ø)
src/types/mmapped.jl 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update facdf3d...8abcb3d. Read the comment docs.

Copy link
Owner

@tlnagy tlnagy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! Would be helpful to update the docs?

@tlnagy
Copy link
Owner

tlnagy commented May 23, 2022

Thanks @timholy. I think this is a reasonable set of changes. Less flexible, but substantially better worse case behavior.

The original approach is a disaster if you do things like JuliaArrays/MappedArrays.jl#49, this PR basically solves that problem completely.

Could you update the precompile statement to improve the time to first image?

mktemp() do fpath, _
for t in [N0f8, N0f16, Float32, Float64]
for c in [Gray, GrayA, RGB, RGBA]
TiffImages.save(fpath, rand(c{t}, 2, 2))
TiffImages.load(fpath)
end
end
end

timholy added 2 commits May 23, 2022 19:05
Also fix a bug in sizing of the slice buffer for 1-byte eltypes
@timholy
Copy link
Contributor Author

timholy commented May 24, 2022

OK, I added precompilation (great suggestion!) and updated the docs.

The doc update will fail, because the docs depend on ImageIO which depends on TiffImages. Consequently, any breaking release of TiffImages will fail its docbuild until a compatible version of ImageIO is released.

If you can refactor it to make the docs independent of ImageIO, it would fix the problem. One possible strategy is to ditch Images.jl for ImageCore.jl; if you can do that ubiquitously then that alone might solve it.

Also, when building the docs I got this warning:

┌ Warning: couldn't find file "/home/tim/.julia/dev/TiffImages/docs/src/examples/<unknown>/examples/writing.jl" when generating URL
└ @ Documenter.Utilities ~/.julia/packages/Documenter/tBpqI/src/Utilities/Utilities.jl:450
┌ Warning: couldn't find file "/home/tim/.julia/dev/TiffImages/docs/src/examples/<unknown>/examples/mmap_lazyio.jl" when generating URL
└ @ Documenter.Utilities ~/.julia/packages/Documenter/tBpqI/src/Utilities/Utilities.jl:450
┌ Warning: couldn't find file "/home/tim/.julia/dev/TiffImages/docs/src/examples/<unknown>/examples/reading.jl" when generating URL
└ @ Documenter.Utilities ~/.julia/packages/Documenter/tBpqI/src/Utilities/Utilities.jl:450
┌ Warning: Documenter could not auto-detect the building environment Skipping deployment.
└ @ Documenter ~/.julia/packages/Documenter/tBpqI/src/deployconfig.jl:75

Not sure if it's important.

@timholy
Copy link
Contributor Author

timholy commented May 24, 2022

Also, something worth considering: I suspect that long-term, we want mmap=true to fail rather than fall back to lazyio=true. The reason is that the performance differences are dramatic, and people may think that they're using mmap when instead they're actually using lazyio.

One could call this effectively a deprecation warning, only bumping the minor version when mmap=true becomes a hard-fail for unsupported file types. However, I suspect that's risky, and it may be better to call this 0.6 and then think about removing the fallback for lazyio in v0.7 (or whatever you want to call it).

src/TiffImages.jl Outdated Show resolved Hide resolved
Co-authored-by: Johnny Chen <johnnychen94@hotmail.com>
@johnnychen94
Copy link
Contributor

Yeeeah~ 🎉 some new errors 😂

@johnnychen94
Copy link
Contributor

I have access to a Windows machine, let me try to fix it locally first.

Comment on lines +27 to +31
seek(file, 0)
rawio = stream(file)
raw = Mmap.mmap(rawio, Vector{UInt8}, filesize(rawio))
chunks = [getchunk(T, raw, reverse(sz), ifd) for ifd in ifds]
return MmappedTIFF{T, N, O, eltype(chunks)}(chunks, ifds, sz)
Copy link
Contributor

@johnnychen94 johnnychen94 May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have access to a Windows machine, let me try to fix it locally first.

I believe timholy#1 fixes the test. But it seems to uncover a windows caveat that mmap-ed images should be surrounded by a let block and eager GC.gc()

timholy#2 is an attempt to eagerly do GC.gc() if issues like this happens. But one should still do GC.gc() before trying to remove the mmapped files. But I'm not sure...

Copy link
Contributor

@johnnychen94 johnnychen94 May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MWE:

using ImageCore
using TiffImages
using ImageShow

img = rand(Gray, 64, 64)
TiffImages.save("tmp.tif", img)

img = TiffImages.load("tmp.tif"; mmap=true)

img2 = TiffImages.load("tmp.tif") # works
TiffImages.save("tmp.tif", img2) # fails -- because img occupies the file handler

With timholy#2

img2 = let img = TiffImages.load("tmp.tif"; mmap=true)
    TiffImages.load("tmp.tif") # works
end
TiffImages.save("tmp.tif", img2) # works -- with an warning
┌ Warning: failed to open file tmp.tif in "w" mode, retry after GC.gc()
└ @ TiffImages d:\Julia\TiffImages.jl\src\utils.jl:205

There seems to be no good way to work around it. All I can imagine is to clearly document
that mmapped images should be used in as small as possible local scope, otherwise, it
would easily lock the file and block or fail IO operations from other tasks.

Copy link
Contributor

@johnnychen94 johnnychen94 May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I believe that we should probably not abuse the load interface. But instead, we should use something similar to the open() context since mmap requests a file resource -- it should follow the C++ RAII (Resource Acquisition Is Initialization) practice.

TiffImages.lazy_load(filepath) do img
    ...
end

could even support multiple files version,

TiffImages.lazy_load(files::Tuple) do imgs
    ...
end

Copy link
Contributor Author

@timholy timholy May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be fine introducing an mmap into FileIO. But for reference, NRRD.jl has been supporting open(filename; mmap=true) for a long time without issue. It's even the default for large images, you have to explicitly turn of mmap if you don't want it. Presumably, it has been real-world tested mostly (or exclusively?) on Linux.

Co-authored-by: 陈久宁 <chenjiuning@tongyuan.cc>
@timholy
Copy link
Contributor Author

timholy commented May 24, 2022

Let's wait for timholy#2 before merging this.

if mmap
ifd = first(ifds)
if mmap && iscontiguous(ifd) && getdata(CompressionType, ifd, COMPRESSION, COMPRESSION_NONE) === COMPRESSION_NONE
return MmappedTIFF(tf, ifds)
Copy link
Contributor

@johnnychen94 johnnychen94 May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's wait for timholy#2 before merging this.

I now don't believe #2 is the correct fix. We should fix the semantics instead.

Note the close(tf.io) in R42 -- this means mmap=true never correctly close the file IO -- the fact that linux CI passes the test is just some bad luck.

Instead, we should use the explicit mmap semantic in timholy#3 and it's a perfect fix for https://github.com/tlnagy/TiffImages.jl/pull/79/files#r880478304

img = rand(Gray, 64, 64)
TiffImages.save("tmp.tif", img)
sz = TiffImages.mmap("tmp.tif") do img
    size(img)
end
img2 = TiffImages.load("tmp.tif") # works
TiffImages.save("tmp.tif", img2) # works

But I can't managed to directly write to the file using mode="w", maybe I'm using it incorrectly?

# it seems that every time when the file is opened, it get cleaned up..
TiffImages.mmap("tmp.tif", mode="w") do img
    img .= img[1]
end

We could even bring this mmap function to FileIO, although I'm not sure if it is feasible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fact that linux CI passes the test is just some bad luck.

Not really, see https://stackoverflow.com/questions/42961339/mmap-after-deleting-the-file. I wish Windows did this. I think that's really the root of all the problems on Windows, and everything works nicely as you'd hope on Linux.

@timholy
Copy link
Contributor Author

timholy commented May 24, 2022

I think these problems with Windows files occur mostly in tests. How often do you really expect this to happen in practice? If someone has gone to the effort to collect a 100GB image series, are they really likely to just delete it?

examples/mmap_lazyio.jl Outdated Show resolved Hide resolved
Comment on lines +70 to +76
# These work only if the file was opened with write permissions
Base.@propagate_inbounds Base.setindex!(img::MmappedTIFF{T, 2}, val, i::Int, j::Int) where T = chunk1(img)[j, i] = val

Base.@propagate_inbounds function Base.setindex!(img::MmappedTIFF{T, 3}, val, i::Int, j::Int, k::Int) where T
chunk = img.chunks[k]
chunk[j, i] = val
end
Copy link
Contributor

@johnnychen94 johnnychen94 May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

julia> img = TiffImages.load("tmp.tif"; mmap=true)

julia> img[2] = img[1]
ERROR: ReadOnlyMemoryError() <--- this seems an enctrypted error message to the first time user
Stacktrace:
  [1] setindex!
    @ .\array.jl:903 [inlined]
  [2] setindex!

An idea here can be explicitly constructing the MmappedTIFF with a bool value so that we can eagerly detect and throw errors.

struct MmappedTIFF{T <: Colorant, N, Writable, O <: Unsigned, A <: AbstractMatrix{T}} <: AbstractTIFF{T, N}
...
end
...

Base.@propagate_inbounds Base.setindex!(img::MmappedTIFF{T, 2, true}, val, i::Int, j::Int) where T = chunk1(img)[j, i] = val
Base.@propagate_inbounds function Base.setindex!(img::MmappedTIFF{T, 3}, val, i::Int, j::Int, k::Int) where T
    chunk = img.chunks[k]
    chunk[j, i] = val
end
function Base.setindex!(img::MmappedTIFF{T, N, false}, val, i::Int, j::Int) where {T,N}
    error("unable to write a readonly memory-mapped array, maybe you need to open the file with `r+` mode.")
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea. Just to decrease need for compilation specialization (some may use both read-only and read-write in the same session), do you think we could do almost as well with register_error_hint? If so, that would require less duplication of compilation effort.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplication of compilation is a valid reason to not do this.

But I'm not sure whether we can register an error hint for ReadOnlyMemoryError because it's an empty struct that we have no extra information
to examine and filter. Can register_error_hint get callstack information in general?

If not doable, the current best approach is just to mention ReadOnlyMemoryError error is due to file mode in the documentation.

Comment on lines 48 to 52
# !!! warning
# Setting values in the array writes those same values to disk!
# Careless use of `mode="r+"` can easily corrupt raw data files.
# You should omit `mode`, or use `mode="r"` (read-only), unless
# you intend to rewrite files.
Copy link
Contributor

@johnnychen94 johnnychen94 May 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these problems with Windows files occur mostly in tests. How often do you really expect this to happen in practice? If someone has gone to the effort to collect a 100GB image series, are they really likely to just delete it?

Although I believe the right fix is to not abuse the load interface, I get your point that we probably
don't need to bother with this edge case issue. Just that I'm not sure how broadly this affects people's
daily life usage.

But I would suggest a clear warning on this issue, especially for windows, and I believe we should also
explicitly document somewhere in the docstring that closing file will be delayed to Julia's GC when
the image object is not used anymore. -- not appropriately closing the file is what I'm worried.

@tlnagy
Copy link
Owner

tlnagy commented May 24, 2022

This PR looks good from my end. I'm open to whatever you all think re: Windows. I won't merge until I get the all clear from @timholy. It's probably a good idea to withhold from updating ImageIO.jl for a bit to let bug reports to come in once I tagged version 0.6.0.

src/load.jl Outdated Show resolved Hide resolved
timholy and others added 2 commits May 24, 2022 20:38
Co-authored-by: Johnny Chen <johnnychen94@hotmail.com>
Copy link
Contributor

@johnnychen94 johnnychen94 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very informative documentation! I guess we just need to merge timholy#2 before merging this PR?

@timholy
Copy link
Contributor Author

timholy commented May 25, 2022

Thanks!This was a very productive discussion and I think users will be better-served for it.

I guess we just need to merge timholy#2 before merging this PR?

I think that makes sense. I left a suggestion for an alternative implementation, but we can go with yours if you prefer it.

It's probably a good idea to withhold from updating ImageIO.jl for a bit to let bug reports to come in once I tagged version 0.6.0.

We should also suppress "canonicalization" if either mmap or lazyio keywords are present, see JuliaIO/ImageIO.jl#51.

@timholy timholy mentioned this pull request May 25, 2022
This attempts to head off a potential bug on Windows: if a file was previously
mmapped in the same session and someone tries to overwrite the file, the presence
of the existing map will trigger a system error. Once can circumvent this if the old map
is garbage-collected. This works only if there are no "live" references to the
memory, but it does not hurt to try.

Perhaps even more importantly, if it fails then an informative warning is displayed.

Co-authored-by: 陈久宁 <chenjiuning@tongyuan.cc>
@timholy
Copy link
Contributor Author

timholy commented May 25, 2022

Presuming it passes tests, I think this is now ready to merge.

@tlnagy
Copy link
Owner

tlnagy commented May 26, 2022

Given that we renamed mmap to lazyio, @timholy would you mind renaming the DiskTaggedImage type as well? The old name doesn't make sense since the new mmapped type is called MmappedTIFF.

@timholy
Copy link
Contributor Author

timholy commented May 26, 2022

How is DiskTaggedImage wrong? I'm not saying there isn't a better name, but I don't see anything misleading about it.

But I did the rename and went with LazyBufferedTIFF. Feel free to suggest other names if you don't like it.

@tlnagy
Copy link
Owner

tlnagy commented May 26, 2022

It's not incorrect per se, but in light of the introduction of the MmappedTIFF type with this PR, it doesn't make much sense to keep the name DiskTaggedImage since they are both "on disk" TIFFs. LazyBufferedTIFF is better since it sticks with the lazy vs mmapped distinction we're going with.

@timholy
Copy link
Contributor Author

timholy commented May 26, 2022

Sounds good to me. I don't have merge privileges, so from my standpoint merge whenever you feel comfortable.

@tlnagy tlnagy merged commit ef62f22 into tlnagy:master May 26, 2022
@tlnagy
Copy link
Owner

tlnagy commented May 26, 2022

Thanks @timholy 🎉 , this will be a welcome performance improvement!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

MultiMappedArrays have pathological performance with memory-mapped arrays
4 participants