[Haskell-cafe] Code Review: Sudoku solver
Claus Reinke
claus.reinke at talk21.com
Mon Apr 10 04:11:47 EDT 2006
>> Mine's even faster now (at least on my computer, would you care to test it
>> on your's? If you don't want to get EnumSet, just change DiffArray to
>> Array, worked wonders for me), I'll dig into yours tomorrow to see what I
>> can get out of it to improve my algorithm.
>
> Unforunately, no new inference rules :-(
indeed, the crucial difference between our versions was that I'd avoided
group inference; I thought it would be expensive to implement and, based
on my own limited experience, rarely used - boy, was I wrong on both
accounts: it is easily implemented, with a little care for the inner loop, not
too inefficient, either, and the examples I mentioned (on which my solver
needed guesswork) simply fall apart once grouping is added (it was quite
impressive to take the output of my old propagation code and apply
grouping by hand!).
now my solver takes slightly fewer guesses than your's, though your's
is still significantly faster (same factor of about 3 between them, both
having improved by a factor of 2 or so..).
> Two things I don't like about your code:
> 1. no type declarations
that's what type inference is for!-) I tend to add them only as debugging
assertions, when language limitations require them, or when I'm fairly
certain that the code is stable (ie., dead;-). it is useful to think about types,
but engraving them into the code is overrated (any good ide should be
able to show them as if they'd been written in anyway..; lacking such ide,
:browse Main will do the trick, though ghci's current formatting is ugly!).
> 2. too much name shadowing, that makes following the code difficult
depends on which parts you mean - I don't like overusing primed or
numbered variables, and using the same variable name through a
sequence of steps is usually the last step before factoring that variable
out into some monad anyway (I did mention that the code was still
ad-hoc, not cleaned up).
> apart from that: clever
as in: you'd prefer it if I'd express myself more clearly?-) agreed.
I've been working on cleaning up the code (also trying to remove
stupidities as they become exposed in the process..).
>> lists and adhoc code tend to be performance killers, I doubled the speed of
>> mine by de-adhoccing the code (and that although I introduced the
>> speed-killer DiffArray)
there's nothing wrong with lists, just as arrays are not synonymous with
good performance - it all depends on the usage patterns. there are several
interesting examples in this problem:
1 when I first tried EnumSet for the ranges, there was no substantial
performance difference to a list-based representation.
in theory, the change sounds like a clear win (constant vs linear time
membership test), but (a) the ranges are small (they start at size 9,
and we do our best to shrink them rapidly, so even if they were at
average size 6 or 7, the test would take an average of 3 or 4 steps);
(b) membership is not the only operation - for the inner loops in my
code, singleton testing and extraction of the single element from a
singleton were equally important, and here the relation was reversed:
lists give us those operations in constant time while they were linear
in the old EnumSet.
Bulat's tabling suggestion (which I interpreted rather more extremely
by tabling the actual calls to size and range2list:-) solves this issue
beautifully, making EnumSet starting to look competitive in spite of
the particular usage pattern in this problem, and when the grouping
inference asked for more calls to size, as well as intersection and
union, the choice flipped completely, now in favour of EnumSet
(has agreement been reached about an updated version of EnumSet?).
[if I might make a suggestion for EnumSet: it is not just that the
implementation of the standard API can be more efficient for these
small sets, but there are additional operations that ought to be
included (unless the EnumSet.Set constructor gets exported),
such as lifting the Ix instance from Word to Set a]
2 DiffArrays: they are neither a speed killer nor a universal cure.
they were intended to enable functional arrays with inplace
update for sequential access, but without the complex analyses
needed to ensure such sequential access, and with the reverse
diff to old versions preserving the persistence of copies.
using them, we'll get inplace update plus a long trail recording
the diffs needed to reconstruct the old versions, and if we use
them single-threadedly, that trail will just be ignored and the
garbage collector will neglect to copy it at some point. but if
we combine them with backtracking, those trails are likely to
kill much of the performance gains, and it is not so much the
width of branching as the depth of each branch: we iterate on
that array to narrow ranges and to update positions, and each
tiny update extends that trail of reverse diffs (they are not even
collapsed, so they can be longer than the array range) - so
when we finally abandon a branch and need to return to any
of the older versions of that array, we are going through that
trail, linearly, to reconstruct that old version from the new one
(doing an identity update before entering a branch should give
us two independent copies, though we'd need to make that
update not on the current version of the array..).
3 Arrays: again, they are no universal performance cure, and
their advantages depend on the usage patterns.
tabling calls to size is an ideal example in favour: a single array
construction vs frequent reads. in contrast, a sequence of
frequent small updates, as in constraint propagation, seems to
be rather disadvantageous: every time we narrow a range or
fix a position, the whole array gets copied! on the other hand,
those arrays are small, and the cost of copying may be lost in
other costs. but if we have to go through many elements,
the cost can be reduced by doing updates in bulk: a _list_ of
updates, with only one new copy being created, incrementally.
there is, however, one part of the problem where I'd expect
arrays to be advantageous, and that is in generating those lists
of updates: here we only inspect the sudoku matrix, reading
its elements according to various more or less complex views.
this part should be efficiently handled over an array, and as
your code shows, doing so may even result in fairly readable
code (although the clarity of my own code has improved as
well since the time when it used to be very concerned with
constructing those several views in rather complex ways;-).
> I believe if you change the representation of puzzles from [(pos,range)]
> to an Array, you'll get a significant speedup
since my solver's logic now seems to be on par, I might try that
for the generation of constraints, but I still doubt it is ideal for the
actual updating of the matrix - perhaps bulk updates might be
sufficient to remedy that.
but the important lesson is that one can leave non-algorithmic
performance concerns to the very end (where they need to be
guided by profiling, and limited to the actual "inner loops") - I
got my solver fairly fast without ever worrying about specialised
representations, or strictness, etc.; focussing on algorithmic
aspects was sufficient and necessary, however:
the naive generate-and-test is impractical, and while simple
constraint propagation will lead to drastic speedups, those are
not sufficient (they still left me in the 10000puzzles/hour stage),
so more careful propagation is needed.
my first narrowing propagation sped up the difficult puzzles, but
slowed down the simple ones (and there are a lot of those in this
set); I had already started on complex heuristics to apply that
propagation selectively before I recalled my own good principles
and looked at the algorithm again, as that slowdown should not
have happened in the first place.
and in fact, it turned out to be related to one of those optimizations
we are all too likely to throw in automatically, without much thought:
to avoid stumbling over the fixed positions as singletons ranges
again and again, I had disabled that propagation, and had instead
left it to each of the other propagation steps to note the appearance
of new singletons, while ignoring the old ones.
so, when my new narrowing step narrowed the range on some
position to a singleton, without notifying anyone, that singleton
got duly ignored by everyone else, assuming that it had already
been taken care of. result: longer searches, in spite of more
information!-( rectifying that, and simplifying some structures,
got the time for the whole set down to the 17min I reported.
on the other hand, one cannot always ignore performance completely,
relying only on the algorithm: but usage patterns and profiling of actual
code are the key here. when I finally added my group inference, it did
slice down the number of guesses for the previously difficult puzzles,
but the actual runtime for the whole set of puzzles increased by a few
minutes! profiling showed some rather extravagantly complex code
("clever" in the worst sense!-) in the innermost loop, cured by
simplifying (and clarifying) the code in question. the next step was
moving to EnumSet, followed by the size/foldBits issue, cured by
Bulat's tabling suggestion. now we're down to 8min20s or so, still
with lists for backtracking and for the main matrix. (compared to
2min40s or so for your's)
> Rot! Typo in _my_ previous message, 5319 is correct.
I changed the output format to a line per puzzle (solution/number
and list of guesses - I still like that metric as each guess means that
we've run out of ideas, which suggests that we want to get the number
of guesses down to zero), using the same format for both solvers to
allow for easy grep/diff.
>> > puzzles involving guesses: 8165
>> > largest number of guesses:
>> > 10 (#9175), 10 (#17200), 10 (#29823), 10 (#30811)
down to 5319 and 0/0/3/0 now.
> We use different guessing strategies (plus, I also have group-inference).
yep, that convinced me that group inference is a must (now added).
as for guessing strategies, you're sorting the non-fixed positions by size
of range before extracting candidates for guesses, whereas I simply take
them in the order they appear in the puzzle (which may have been
somewhat hard to see thanks to another one of those overcomplicated
pieces of code that has now disappeared..). I had thought about that, but
couldn't make up my mind about preferring any order, and while the
numbers of guesses for our two solvers vary wildly between puzzles,
the total of guesses shows no advantage of sorting (the total looks
slightly smaller for my solver, it has no two-digit guesses, and fewer
puzzles with more than 6 guesses, so if anything, the evidence is against
sorting, but I'd rather focus on reducing the guesses than try to attach
any significance to these variations..).
if I eliminate the sorting from your solver, both solvers deliver the same
results, so that's nice!-) but it also means that we have room for further
improvement.
one reason why I got interested in this problem in the first place was
that I had been reading about constraint propagation, and wanted to
get some hands-on experience. in particular, there is a technique
called "generalized propagation":
Generalised Constraint Propagation Over the CLP Scheme,
by Thierry Le Provost and Mark Wallace. Journal of Logic
Programming 16, July 1993. Also [ECRC-92-1]
http://eclipse.crosscoreop.com:8080/eclipse/reports/ecrc_genprop.ps.gz
if I may oversimplify things a bit:
1 split inferences into branching (search) and non-branching (propagation)
2 distribute common information from non-branching inferences
out of branches
3 to enhance applicability of step 2, add approximation of information
applied to our sudoku problem, we have deterministic propagation
and branching search, and what we do if we run out of propagation
opportunities is to select a non-fixed position, then branch on each
of the numbers in its range. adding generalised propagation (as I
understand it) amounts to a form of lookahead: we take each of
those branches, but look only at the deterministic information we
can extract in each (ignoring further search). then we check whether
the branches have any of that information in common, and apply any
such common information to our matrix. rinse and repeat. an obvious
way to refine this process by approximation is to build the union of
the ranges on each position for the matrices resulting from all the
branches.
using this idea with a single level of branch lookahead, and selecting
a position with the widest range for inspection, reduces the number
of puzzles requiring guesses to 373, with a maximum depth of 3
guesses.
it doesn't reduce runtime much, yet (7min50s), but I haven't
even started profiling, either. I guess I have to look into
representations now, if I ever want my solver to catch up with
your's in terms of runtime;-)
cheers,
claus
More information about the Haskell-Cafe
mailing list