I will show how similar tasks can be done using (undocumented) Streaming framework. The usual caveat applies: since this is undocumented functionality, there is no guarantee that it will exist in the future versions in the same exact form, or at all. But I thought it may be a nice application to illustrate some of the ideas behind Streaming, as well as to have a somewhat deeper look into its internals and possible use cases.
Preparation
Download and install a patch for Streaming, if don't yet have it
To start with, one would have to follow this Q/A to patch Streaming, which is necessary to run it on modern versions of Mathematica. Specifically, you need to execute:
Import[StringJoin[
"https://raw.githubusercontent.com/",
"lshifr/StreamingPatch/master/StreamingPatchBootstrap.m"]
]
DownloadAndInstallStreamingPatch[]
where I used StringJoin
because the bug in SE code editor would not allow me to enter long strings. This step has to be done only once on a given machine.
Streaming and LazyTuples
Since my post has become very long, I have collected essential parts into a gist, which can be easily imported. For lazy tuples, I will use the function TuplesFunction
from this excellent answer by Carl Woll, with a few minor changes.
The initialization code is then:
$HistoryLength = 0;
Get["StreamingPatch`"];
Import @ StringJoin[
"https://gist.githubusercontent.com/lshifr/",
"7d5d3ef064b2eb1a1e9b997726923331/raw/LazyTuples.m"
]
Example data
We start by defining sample lists for tuple elements:
lists = Table[RandomSample[Range[30], 20], 5]
(*
{
{4, 5, 7, 16, 20, 17, 14, 6, 22, 25, 12, 15, 19, 1, 2, 13, 29, 26, 10, 3},
{7, 4, 21, 28, 29, 3, 10, 9, 15, 12, 17, 14, 1, 11, 16, 2, 23, 30, 6, 5},
{1, 7, 4, 18, 17, 21, 16, 30, 26, 11, 3, 22, 6, 14, 24, 19, 12, 23, 9, 28},
{28, 23, 30, 4, 11, 16, 27, 20, 6, 14, 24, 18, 12, 3, 7, 21, 2, 25, 26, 1},
{11, 17, 5, 1, 18, 25, 4, 12, 16, 24, 2, 9, 10, 8, 3, 23, 28, 26, 19, 15}
}
*)
This defines tuples with the length 20^5 == 3200000
and total ByteCount
about 128Mb:
Times @@ Length /@ lists
ByteCount[Tuples[lists]]
(* 3200000 *)
(* 128000208 *)
Main example
LazyList brief intro
Evaluating LazyTuples[lists]
will create a LazyList
object, which is a lazy representation of a list of tuples, with a default chunk size 10000
.
Sections below have more detailed explanations of the internals, but for practical purposes one can in many ways use LazyList
in place of a normal List
. Many operations on LazyList
, such as e.g. Select
and Take
, are lazy - they produce a new LazyList
with very little computation. The real work is done when Normal
is applied to a LazyList
object, which converts it to an equivalent normal List
.
Illustration
This creates a LazyList
object (delayed assignment is deliberate, for the purposes of benchmarking we want a new instance to be created every time):
lt := LazyTuples[lists]
One can convert it to a normal list
Normal[lt] // Length // AbsoluteTiming
(* {1.3135, 3200000} *)
and verify that it produces the result identical to standard Tuples
:
Normal[lt] == Tuples[lists]
(* True *)
In the following, we will search for all tuples which sum exactly to 100
, and measure performance and memory footprint:
Normal @ Select[lt, Total[#]==100&]//Short//AbsoluteTiming
Normal @ Select[lt, Total[#]==100&]//MaxMemoryUsed
Normal @ Take[Select[lt, Total[#]==100&], 1]//AbsoluteTiming
(*
{5.91346,{{4,21,17,30,28},{4,21,21,28,26},{4,21,21,30,24},{4,21,21,26,28},<<23635>>,{3,30,28,14,25},{3,30,28,24,15},{3,30,28,21,18}}}
7595088
{0.108224,{{4,21,17,30,28}}}
*)
This shows that it takes about 6 seconds to process entire list of tuples in this case, and about 0.1 sec to get just the first match, while memory use does not exceed 8Mb.
We now compare that to in-memory computations using Tuples
:
Select[Tuples[lists], Total[#]==100&]//Short//AbsoluteTiming
Select[Tuples[lists], Total[#]==100&]//Short//MaxMemoryUsed
Select[Tuples[lists], Total[#] == 100 &, 1]//Short//AbsoluteTiming
(*
{4.55724,{{4,21,17,30,28},{4,21,21,28,26},{4,21,21,30,24},{4,21,21,26,28},<<23635>>,{3,30,28,14,25},{3,30,28,24,15},{3,30,28,21,18}}}
692115784
{1.09674,{{4,21,17,30,28}}}
*)
which shows that in-memory computation is about two orders of magnitude more memory-hungry, about 1.5x - 2x faster for all matches, and significantly slower for a single match search.
Note that run-time and memory efficiency of lazy computations in general do depend on the chunk size. One can specify it using "ChunkSize"
option to LazyTuples
. For example, this would create a LazyList
with larger chunks:
LazyTuples[lists, "ChunkSize" -> 50000]
Previous editions of this post contain more in-depth explanations of the internals of LazyTuples
.
Speeding things up
Using compiled predicate
We can compile the predicate in Select
:
selFun = Compile[{{ints, _Integer, 1}}, Total[ints] == 100, "CompilationTarget" -> "C"]
which would give about 1.5x - 2x speedup (for both lazy and in-memory computations):
Normal @ Select[lt, selFun];//AbsoluteTiming
Normal @ Select[lt, selFun]//MaxMemoryUsed
Normal @ Take[Select[lt, selFun], 1]//AbsoluteTiming
(*
{3.28482,Null}
4941080
{0.075051,{{4,21,17,30,28}}}
*)
Compiling entire Select
This section will show how Streaming can actually beat in-memory computations not only by memory efficiency, but also by speed.
Let's start by asking, whether one can further speed things up. One obvious improvement is to try compiling entire Select
, rather than just the predicate.
The following function is a generator of compiled Select
functions, which take (preferably compiled) predicate and return compiled Select
:
ClearAll[createTupleSelect]
createTupleSelect[criteria_] := createTupleSelect[criteria, All]
createTupleSelect[criteria_, numberOfResults_] :=
Replace[
If[
numberOfResults === All,
Hold[Select[tuples, criteria]],
Hold[Select[tuples, criteria, numberOfResults]]
],
Hold[select_] :> Compile @@ Hold[
{{tuples, _Integer, 2}},
select,
CompilationTarget->"C", CompilationOptions -> {"InlineCompiledFunctions"-> True}
]
]
Let us use it to create two compiled functions, one that selects all matching elements, and the other that selects just the first one:
sel = createTupleSelect[selFun]
selFst = createTupleSelect[selFun, 1]
We can test these easily by using them for the in-memory computation:
sel[Tuples[lists]]//Short//AbsoluteTiming
selFst[Tuples[lists]]//Short//AbsoluteTiming
(*
{0.413269,{{4,21,17,30,28},{4,21,21,28,26},{4,21,21,30,24},<<23636>>,{3,30,28,14,25},{3,30,28,24,15},{3,30,28,21,18}}}
{0.283024,{{4,21,17,30,28}}}
*)
We see that this version is about 5-6 times faster than the one where only the predicate has been compiled.
But what is remarkable here, is that the time to get a single result is almost the same as the time to get all results. This is because, for the in-memory computation, one first has to generate all tuples, and in the compiled setup this operation takes the majority of time. This is where lazy computations potentially have an advantage. Let's see whether we actually can have it with Streaming.
To test Streaming in this regime, we will switch to larger chunks - that would reduce the top-level overhead of Streaming chunk manipulation:
ltLrg := LazyTuples[lists, "ChunkSize" -> 50000]
Unfortunately, the implementation of Streaming Select
does not have an option to provide an entire select function for a chunk, rather than a predicate. I have created a gist where I added such capability. You can also look at it if you are interested in how Select
is implemented in Streaming currently, since it is mostly exact same code.
We will import it now:
Import[StringJoin[
"https://gist.githubusercontent.com/lshifr/",
"2fa8ff7c950595d2ca32adc41b76bd7d/raw/LazyListSelect.m"
]]
which will make a new LazyListSelect[list, pred, entireSelect]
function available (again, the only reason for StringJoin
is the M SE editor bug).
We can now use it:
Normal @ LazyListSelect[ltLrg, None, sel]//Short//AbsoluteTiming
Normal @ LazyListSelect[ltLrg, None, sel]//MaxMemoryUsed
Normal @ Take[LazyListSelect[ltLrg, None, selFst],1]//AbsoluteTiming
(*
{0.67473,{{4,21,17,30,28},{4,21,21,28,26},{4,21,21,30,24},{4,21,21,26,28},<<23635>>,{3,30,28,14,25},{3,30,28,24,15},{3,30,28,21,18}}}
9266760
{0.047092,{{4,21,17,30,28}}}
*)
What we see is that the search for all matches is about 1.5x - 2x times slower than the in-memory version, which is similar to what we have seen before. In this case, it is probably mostly due to the tuples generation overhead (even though Carl's function is extremely fast, it is still top level compared to kernel's specialized implementation in C). But of course this is still an order of magnitude more memory efficient.
But for a single match search, we're in for a pleasant finding. Not only is this an order of magnitude more memory efficient than direct built-in, but it is also an order of magnitude faster.
Of course, as I mentioned previously, this isn't really surprising, since lazy computations don't need to generate all tuples if the result can be found within e.g. the first few thousands. And of course one can write imperative code with loops, which would lead to similar or somewhat better performance. But Streaming has all that built in, and allows one to stay within the declarative functional paradigm, while providing decent performance in many such cases.
Summary
I have outlined how problems such as this can be treated within Streaming framework.
The main advantages of Streaming are built-in memory efficiency and laziness, which can automatically lead to efficient computations when only a subset of data is needed at the end, while allowing one to stay within the declarative / functional programming paradigm.
In some cases, Streaming can be not only vastly more memory-efficient than analogous in-memory computations with built-ins, performed in functional style, but also much faster. I have illustrated that in the section on further possible speedups, with entirely compiled Select
.
In many cases, when using Streaming, one can control the memory / speed balance by adjusting the chunk size of the computation.
{3, 7, 6}
considered a possible answer? $\endgroup$s
. $\endgroup$