Consignes
Le rendu de ce TP se fera uniquement par TPLab et consistera en une archive (zip, tar, etc.) contenant un répertoire TP2-NOM avec :
-
votre fichier solve-NOM.c complété
Avec le fichier test-NOM.c (que vous n'envoyez pas), c'est le seul fichier que vous devrez modifier !
-
un fichier texte README contenant les réponses aux questions du TP
-
tout autre fichier que vous jugerez pertinent (fichiers de tests, graphiques, benchmarks, etc.)
Vous aurez compris que NOM devra être remplacé par votre nom (ou vos noms en cas de binomes) !
Tous vos fichiers doivent comporter une entête avec votre noms et votre groupe de TP.
Attention : votre code doit compiler avec les options suivantes :
(Vous pouvez éventuellement ajouter les options -Wno-unused-parameter et -Wno-unused-variable, en particulier pendant le développement.)
Le fichier Makefile fournit compile automatiquement avec ces options.
Tout non respect d'une ou plusieurs de ces consignes entrainera automatiquement un retrait de points sur votre note !
Outils nécessaires, prérequis
Pour ce TP, vous devez disposer d'un environnement de type Unix avec
-
un compilateur C (gcc ou clang),
-
la commande make,
-
la commande gprof et/ou l'outil valgrind,
-
le programme minisat pour tester et comparer notre solveur avec un solveur performant (dernière question).
Debian / Ubuntu
Les paquets suivants devraient être suffisants et sont probablement déjà installés (à part minisat) :
$ sudo apt install gcc make minisat
Vous pouvez ajouter quelques autres paquets, mais ce n'est pas obligatoire.
$ sudo apt install clang valgrind kcachegrind
macOS
Pour les étudiants qui veulent faire leur TP sous macOS, il est impératif que vous installiez ces outils avant la séance :
-
clang
-
make
-
valgrind
-
minisat (s'il n'est pas disponible dans les paquets officiels, vous pouvez le compiler directement : https://github.com/niklasso/minisat)
Point important
Cette séance de TP est ambitieuse.
Les temps de réflexion et assimilation des concepts seront aussi importants que les temps de codage. N'hésitez pas à demander des explications lorsque certains points ne sont pas clairs.
Nous vous conseillons d'anticiper la séance et de faire la partie préliminaire avant le début de la séance.
Liens utiles
-
code à compléter : TP2-info501.tgz
-
la pages des codes de D. Knuth qui a écrit un petit livre sur les SAT solveur. Nous allons essentiellement faire les versions SAT0W et SAT10
-
la page Wikipedia sur l'algorithme DPLL
-
la page de minisat, un petit SAT solveur écrit en C++ qui fait du "conflict-driven clause learning". Ce programme est disponible sous Debian / Ubuntu.
Préliminaires
L'objectif de ce TP est de regarder comment les SAT solveurs sont écrits. La version utilisée dans le TP1 permet de comprendre le principe théorique, mais l'efficacité n'était pas trop au rendez vous !
Nous allons donc coder un SAT solveur (du style DPLL), en C, en utilisant des structures de données adaptées.
Rappels : le problème
Étant donnée une formule en forme normale conjonctive (FNC), comme (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4) le solveur doit chercher des valeurs pour x1, ..., x4 qui rendent la formule vraie.
La méthode standard utilise le backtracking : on essaie des solutions, et quand elles ne conviennent pas, on modifie la dernière décision possible.
La version naïve du TP1 était récursive, mais pour gagner en performances, nous allons devoir passer à une version itérative.
La structure de données utilisée : variables, littéraux et clauses
Chaque formule en FNC, comme (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4) sera représentée par 2 tableaux :
-
Lit
, qui donne la liste de tous les littéraux apparaissant dans la formule. (Il y a par exemple 8 littéraux dans la formule ci dessus.)Chaque littéral sera représenté par un entier :
-
2k+1 pour le littéral xk
-
2k pour le littéral ¬xk
Il n'y a pas de variable x0. Les littéraux commencent donc à 2.
On peut récupérer :
-
le "signe" d'un littéral en prenant son bit de poids faible :
lit & 1
(macroSIGN(lit)
dans le code fourni) -
la "variable" d'un littéral en faisant un décalage de bits :
lit >> 1
(macroVARIABLE(lit)
dans le code fourni)
Pour la formule ci dessus, le tableau ressemblerait à
clause 0
clause 1
clause 2
littéral x1 ¬x2 x3 ¬x1 ¬x3 x4 x2 ¬x4 i
0
1
2
3
4
5
6
7
Lit[i]
3
4
7
2
6
9
5
8
-
-
Cl
, qui donne pour chaque clause (0
,1
et2
ci dessus), l'indice du premier littéral de la clause. Ici, on auraitk
0
1
2
3
Cl[k]
0
3
6
8
Notez que le tableau
Cl
contient une case supplémentaire pour savoir où se termine la dernière clause : la clausek
contient les littéraux deCl[k]
jusqu'àCl[k+1]-1
(inclus).
Ces deux tableaux, avec le nombre de variables, clauses et littéraux sont stockés dans une structure :
typedef struct {
int nb_var; // number of different variables x_1, ..., x_n
int nb_cl; // number of clauses in the CNF formula
int nb_lit; // total number of literals in the CNF formula
int* Lit; // array of literals (of size nb_lit)
int* Cl; // array of size nb_cl+1 giving the start of each clause in the Lit array
} formula_t;
La structure contient également un tableau VarName
dont vous
n'aurez pas besoin. Ce tableau contient, pour chaque variable i
,
une chaine de caractères pour l'affichage. Cela permet de lire plus facilement
les solutions trouvées.
Avant de commencer le TP, vous devez :
-
télécharger le code fourni et extraire les fichiers de l'archive
-
changer le nom des fichiers test-NOM.c et solve-NOM.c en utilisant votre nom
-
changer la valeur de la variable NAME dans le fichier Makefile fourni (deuxième ligne du fichier)
-
compiler le code avec la commande make
$ make gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 -c main.c gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 -c utils.c gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 -c print.c gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 -c test-correction.c gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 -c naive.c gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 -c solve-correction.c gcc -std=c99 -Wall -Wextra -pedantic -Werror -O4 main.o utils.o print.o test-correction.o naive.o solve-correction.o -o sat
-
l'utilisation usuelle se fait avec la commande sat, éventuellement avec des options :
$ ./sat [OPTIONS] [FICHIER.cnf]
Le programme initialise alors les structures de données pertinentes avec le contenu du fichier (ou de l'entrée standard si aucun nom de fichier n'est fourni).
Vous pouvez appeler la fonction de tests (que vous pourrez compléter au fur et à mesure du TP) du fichier test-NOM.c. Par exemple, le test struct permet d'afficher le contenu de la structure correspondant à la formule lue dans le fichier correspondant :
$ ./sat -T struct CNF-files/test-001.cnf
The formula contains 4 variable(s), 3 clause(s) for a total of 8 literal(s)
nb_var = 4
nb_cl = 3
nb_lit = 8
Cl[0] = (0)
Cl[1] = (3)
Cl[2] = (6)
Cl[3] = (8)
Lit[0] = 3, càd 1
Lit[1] = 4, càd -2
Lit[2] = 7, càd 3
Lit[3] = 2, càd -1
Lit[4] = 6, càd -3
Lit[5] = 9, càd 4
Lit[6] = 5, càd 2
Lit[7] = 8, càd -4
La fonction affiche également le contenu du tableau VarName
que
vous pouvez ignorer.
Complétez la fonction print_CNF
dans le fichier solve-NOM.c. Cette fonction affiche une formule de
manière "lisible", avec une clause par ligne.
$ ./sat -T CNF CNF-files/test-001.cnf
The formula contains 4 variable(s), 3 clause(s) for a total of 8 literal(s)
1 -2 3
-1 -3 4
2 -4
La formule est donc (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4).
Données importantes :
-
les champs de la structure
F
:F->nb_lit
,F->Lit
, etc. -
LIT2INT
pour transformer un nombre (littéral) de la forme 2k+1 en +k et 2k en -k,
Le corps de ma fonction fait 6 lignes (dont deux lignes ne contenant que
}
).
L'algorithme naïf
Les solutions partielles
La plupart des algorithmes essaient d'agrandir une solution partielle jusqu'à trouver une vraie solution, ou une contradiction (auquel cas, ils reviennent en arrière). Une telle solution pourrait par exemple être "x3=FALSE, x1=TRUE".
Les solutions partielles sont représentées par 2 tableaux
-
Var
, qui contient les (numéros des) variables de la solution actuelle, par exemple{3, 1}
.La taille de
Var
, appeléen
, évolue au cours de l'exécution : elle augmente lorsqu'on ajoute une variable à la solution, et elle diminue lors du backtracking quand on est arrivé à une solution incorrecte.La valeur de
n
peut devenir négative (-1
) lorsque le backtracking échoue, et supérieure au nombre total de variables lorsque que l'algorithme a trouvé une solution. -
State
, de taille1+nb_var
, qui donne, pour chaque variable x1, ..., x4 l'état actuel de la variable : RIEN (pour les variables qui ne sont pas dans la solution partielle), VRAI ou FAUX, par exemple{RIEN, TRUE, RIEN, FALSE, RIEN, ...}
Comme il faut se souvenir des valeurs déjà testées, il y a en fait 5 états distincts. Dans le code fourni, ces états sont appelés
UNSET
,FALSE
,TRUE
,FALSE_WAS_TRUE
etTRUE_WAS_FALSE
.Ces constantes sont des synonymes pour
-1
,0
,1
,2
et3
, et on obtient (sauf pourUNSET
) la valeur booléenne correspondante en regardant le bit de poids faible de l'état :State[x] & 1
.
Ces données sont elles aussi stockées dans une structure :
typedef struct {
int n; // number of variable in the current assignement
int* Var; // array of variables in the current assignement: the array is of size nb_var, but
// only the first n entries are relevant. The other ones should be UNSET (-1).
int* State; // array of values ("states") of the variable. Variables not in the Var array should
// have value UNSET (-1).
} sol_t;
Le "jeux" SAT Game donne une assez bonne intuition sur cette représentation :
-
le tableaux des littéraux correspond à toutes les cases
-
chaque case contient un littéral, et peut avoir une valeur
UNSET
(gris),TRUE
(vert) ouFALSE
(rouge),
Faites quelques parties avant de continuer !
Il sera important, lorsque notre algorithme a trouvé une solution, de vérifier que la solution ... est bien une solution.
Pour cela, il faut parcourir les clauses de la formule F
, et
pour chacune d'entre elle, vérifier qu'elle contient un littéral vrai,
c'est à dire, un littéral pour lequel la valeur booléenne de la variable est
égale à son signe :
-
pour que ¬x (variable x, signe 0) soit vrai, il faut que la valeur de x soit 0,
-
pour que x (variable x, signe 1) soit vrai, il faut que la valeur de x soit 1.
Écrivez la fonction is_solution(formula_t* F, sol_t* S)
qui
vérifie que la solution S
rend bien la formule F
vraie.
Données importantes :
-
le tableau
S->State
qui contient l'état de chaque variable (UNSET
,FALSE
,TRUE
,FALSE_WAS_TRUE
ouTRUE_WAS_FALSE
) -
la valeur booléenne correspondante s'obtient en prenant le bit de poids faible d'un état (sauf si c'est
UNSET
, auquel cas la variable n'a pas de valeur), -
les macros
VARIABLE
etSIGN
qui permettent de récupérer la variable et le signe d'un littéral.
Ma fonction fait une quinzaine de lignes et ressemble à ça :
... .... .. . .. .. . ......... ..... .
... . . ..
... .... . . .......... . . ........ . ... .... .
... ... . ..........
... . . ..............
... . . ..........
.. ............ .. ..... .. ............ . .. .. .. .
. . ..
......
.
.
.. .. .. .. .
...... ..
.
.
...... ..
Cette fonction est automatiquement appelée dans la fonction main
(fichier main.c) lorsque que le solveur a
trouvé une solution. Avant modification, cette fonction renvoie
0
, ce qui provoque le message suivant :
$ ./sat -N CNF-files/test-001.cnf
SATISFIABLE
1 2 3 4
*** IS THAT REALLY A SOLUTION?
Attention : n'oubliez pas l'option -N sur la ligne de commande. Sans cela, c'est le solveur optimisé (à trous) qui sera appelé !
Le code
L'algorithme itératif naïf sur ces structure de données ressemble à
int solve_naive(formula_t* F, sol_t* S)
{
// NOTE : S->n est initialisé à 0
while (0 <= S->n && S->n < F->nb_var) {
// on regarde la n-ème variable de la solution actuelle :
if (S->Var[S->n] == UNSET) {
// il faut choisir une variable à ajouter à la solution actuelle
// on peut prendre les variables dans l'ordre : 1, 2, 3, .
// (Attention au décalage de 1 : les variables commencent à 1.)
S->Var[S->n] = S->n + 1;
// il faut aussi choisir une valeur pour cette variable
// on peut prendre VRAI.
S->State[S->Var[S->n]] = TRUE;
} else if (S->State[S->Var[S->n]] == TRUE || S->State[S->Var[S->n]] == FALSE) {
// si la variable avait déjà une valeur, il faut en choisir une autre
// on change la valeur de TRUE (1) à FALSE_WAS_TRUE (2) et de FALSE (0) à TRUE_WAS_FALSE
// (3)
S->State[S->Var[S->n]] = 3 - S->State[S->Var[S->n]];
}
if (is_non_false(F, S)) {
// si les valeurs des variables ne rendent pas la formule fausse, on continue
S->n++;
} else {
// sinon, il faut revenir en arrière !
// NOTE : la fonction backtrack_naive diminue la valeur de S->n
backtrack_naive(S);
}
}
if (S->n < 0) {
// non satisfiable
return 0;
} else {
// satisfiable
return 1;
}
}
La fonction backtrack_naive
est assez simple : elle enlève des
variables d'une solution partielle pour obtenir une dernière variable dont on
peut changer la valeur (càd une variable où on a testé qu'une seule valeur
booléenne). Cette fonction modifie la structure S
! Il est
possible que la solution renvoyée soit vide (càd avec S->n ==
-1
). Ceci arrive lorsqu'on a testé toutes les solutions possible et que
la formule n'est pas satisfiable.
int backtrack_naive(sol_t* S)
{
// tant que la dernière variable de la solution à été testée sur les 2 valeurs
while (S->n >= 0 && (S->State[S->Var[S->n]] == TRUE_WAS_FALSE ||
S->State[S->Var[S->n]] == FALSE_WAS_TRUE)) {
S->State[S->Var[S->n]] = UNSET; // on rénitialise cette variable
S->Var[S->n] = UNSET; // on la supprime de la solution courante
S->n--;
}
return S->n;
}
La fonction is_non_false
n'est pas très difficile mais nécessite
de parcourir le tableau Lit
. Elle regarde si une clause
est fausse, càd ne contient que des littéraux dont la valeur est FAUX. Elle
ressemble donc à
int is_non_false(formula_t* F, sol_t* S)
{
// pour toutes les clauses
for (int cl = 0; cl < F->nb_cl; cl++) {
// on calcule la valeur de vérité
int clause_value = 0;
// pour tous les littéraux de la clause
for (int k = F->Cl[cl]; k < F->Cl[cl + 1]; k++) {
int lit = F->Lit[k]; // littéral courant
int x = VARIABLE(lit); // variable correspondante
int s = SIGN(lit); // signe correspondant
int v = S->State[x] & 1; // valeur de la variable dans la solution
// si ce littéral n'est pas faux, la clause n'est pas fausse !
if (S->State[x] == UNSET || s == v) {
clause_value = 1;
break;
}
}
if (clause_value == 0) {
return 0;
}
}
return 1;
}
La version naïve en Python faisait un quinzaine de lignes. Cette version
naïve en C en fait une quarantaine (si on supprime les commentaires et autres
lignes inutiles, et les lignes ne comportant que "}
").
Ce code se trouve dans le fichier naive.c. Vous pouvez l'utiliser en donnant l'option -N ou --naive sur la ligne de commandes :
$ ./sat -N CNF-files/test-001.cnf
...
Pour obtenir des affichages sur le fonctionnement de l'algorithme, utilisez l'option -v ou --verbose (éventuellement plusieurs fois) :
$ ./sat -Nv CNF-files/test-001.cnf
...
$ ./sat -Nvv CNF-files/test-001.cnf
...
$ ./sat -Nvvv CNF-files/test-001.cnf
...
$ ./sat -Nvvvv CNF-files/test-001.cnf
...
Vérifiez que vous comprenez les étapes de la recherche de solution en utilisant les formules suivantes :
-
fichier simple-SAT.cnf
-
fichier simple-NOSAT.cnf
-
fichier exp-naive-SAT.cnf
N'hésitez pas à demander des explications à votre encadrant si vous n'êtes pas sûr de ce que vous observez.
Invariants et assertions
Lors de l'exécution, les solutions partielles doivent vérifier la contrainte suivante :
-
le tableau
Var
contient des entiers strictement positifs jusqu'àn
, et desUNSET
après, -
toutes les variables qui apparaissent dans
Var
ont un état différent deUNSET
, et celles qui n'apparaissent pas dansVar
ont un étatUNSET
. -
Attention,
Var[n]
peut contenir une variable (càd un nombre strictement positif), mais dans ce cas, son état doit êtreTRUE
ouFALSE
. (C'est ce qui se passe lors du backtracking:Var[n]
contient la variable dont on doit changer la valeur.)
Graphiquement, la solution partielle "x3=FALSE, x7=TRUE, x2=TRUE, ..." est représentée par
i
|
0
|
1
|
2
|
... |
n
|
...... | ||
Var[i]
|
3
|
7
|
2
|
... | ??? |
UNSET
|
... |
UNSET
|
x
|
0
|
1
|
2
|
3
|
4
|
5
|
6
|
7
|
... |
State[x]
|
UNSET
|
TRUE
|
FALSE
|
UNSET
|
UNSET
|
UNSET
|
TRUE
|
... |
La seule case ambigüe est Var[n]
, dont la valeur peut être à
UNSET
(il faut choisir une nouvelle variable) ou
FALSE
ou TRUE
(il faut modifier la valeur de la
variable correspondante).
Une telle propriété, qui doit rester vraie pendant toute la durée d'un programme (ou d'une fonction) est appelée un invariant.
Lors du développement, il est important de mettre en place une vérification des invariants pour détecter l'apparition de bugs le plus tôt possible.
La fonction assert
du langage C (#include
<assert.h>
) permet de vérifier une condition. Lorsque cette
condition n'est pas vérifiée, le programme est stoppé, et lorsque la condition
est vérifiée, assert
ne fait rien.
-
Complétez la fonction
check_sol
qui vérifie l'invariant sur les solutions partielles.Votre fonction doit renvoyer
1
lorsque les conditions sont vérifiées. Si les conditions ne sont pas vérifiées, votre fonction peut soit stopper le programme (c'est ce qui se passe si vous avez utilisé unassert
pour vérifier certaines des conditions) ou renvoyer0
.(Attention, si vous l'écrivez, cette fonction sera appelée dans la suite du TP. Si elle n'est pas correcte, elle risque de stopper l'exécution du solveur.)
Pour éviter de parcourir plusieurs fois le tableau
Var
, vous pouvez utiliser un tableau local (SET
dans le code fourni) :-
Un premier parcours du tableau
Var
permet de vérifier qu'il y a bien des variables au début et desUNSET
à la fin.Ce parcours permet également de mettre
SET[x]
à1
pour toutes les variables qui apparaissent dans la solution. -
Un parcours du tableaux
SET
permet de vérifier que chaque variable de la solution à un état autre queUNSET
et que les autres ont un étatUNSET
.
Attention : à bien gérer la case
n
du tableauVar
! -
-
Ajoutez une ligne
assert(check_sol(F, S));
au début du corps de la boucle principale de la fonctionsolve_naive
.
1. Amélioration de la structure de données : "watch lists"
Profilage
Presque toute la complexité de la version naïve vient de la fonction
is_non_false
, qui parcourt à chaque fois l'intégralité du tableau
Lit
. La version Python du TP1 ne parcourait que des versions
simplifiées de la formule, ce qui est un avantage non négligeable. (Par
contre, le mécanisme de récursion nécessite de conserver en mémoire toutes les
versions de la formule.)
La démarche de regarder d'où vient la complexité d'un programme de manière expérimentale s'appelle le profilage, ou profiling. Il existe des outils pour faire ceci. Deux de ces outils sont
-
compilation en mode "debug", et utilisation de gprof
-
compilation "normal", et utilisation de l'outils callgrind de valgrind.
Vous pouvez choisir l'un ou l'autre de ces outils pour faire la question suivante.
-
gprof est légèrement plus simple d'utilisation mais nécessite une compilation en mode debug. D'autre part, il n'est pas disponible sous macOS
-
valgrind est beaucoup plus complet, mais aussi plus difficile à utiliser. L'exécution est également nettement plus lente. Il peut s'utiliser sous macOS.
Utilisation de gprof
-
Compiler son code avec l'option -pg (mode "debug"). Ce n'est pas obligatoire, mais il est en général préférable de désactiver les optimisation du compilateur avec l'option -O0.
-
Lancer le code normalement, avec ./sat .... Ceci génèrera un fichier gmon.out dans le répertoire courant.
-
Lancer la commande gprof sur l'exécutable : gprof sat.
Les premières lignes du résultat donneront le temps relatif passé dans chaque fonction. On peut par exemple obtenir quelque chose comme (sur un autre programme que sat)
$ gprof edc Flat profile: Each sample counts as 0.01 seconds. % cumulative self self total time seconds seconds calls ms/call ms/call name 36.16 0.30 0.30 50449359 0.00 0.00 PLOG4 19.28 0.46 0.16 344073 0.00 0.00 shift 18.08 0.61 0.15 344071 0.00 0.00 DECIPHER ...
Pour modifier les options de compilation, vous pouvez modifier la variable FLAGS définie en haut du fichier Makefile. Utilisez la ligne (commentée) qui contient les options -pg -O0.
Utilisation de callgrind
-
Compiler son code sans optimisation avec l'option -O0.
-
Exécuter le code avec l'outils callgrind :
valgrind --tool=callgrind --callgrind-out-file=callgrind.out EXE OPTIONS``
(Dans notre cas, EXE sera ./sat et OPTIONS, les options passées à ./sat.)
Attention, l'exécution est nettement plus lente car le code est "surveillé" par valgrind.
-
Visualiser avec la commande callgrind_annotate --threshold=99.5 callgrind.out
Le résultat donnera le nombre absolu d'instructions assembleur exécutées par chaque fonction. On peut par exemple obtenir quelque chose comme (sur un autre programme que sat)
$ callgrind_annotate --threshold=99.5 callgrind.out [13:52] <168 076 137 (1945)> -------------------------------------------------------------------------------- Profile data file 'callgrind.out' (creator: callgrind-3.14.0) -------------------------------------------------------------------------------- ... <<< QUELQUES LIGNES SUPPRIMÉES >>> ... -------------------------------------------------------------------------------- Ir -------------------------------------------------------------------------------- 12,360,561,053 PROGRAM TOTALS -------------------------------------------------------------------------------- Ir file:function -------------------------------------------------------------------------------- 2,851,051,876 ???:ENCIPHER_PAIR [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc] 2,433,473,810 ???:DECIPHER [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc] 2,392,888,698 ???:PLOG4 [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc] 1,421,313,000 ???:shift [/export/home/hyvernat/doc/Enseignement/2021/info910-crypto/TP/TP-historical/correction/playfair/edc] ...
Au total, 12360561053 instructions ont été exécutées, dont 2851051876 par la fonction
ENCIPHER
, 2433473810 par la fonctionDECIPHER
, etc. -
Sous Linux, vous pouvez aussi utiliser kcachegrind pour visualiser le résultat sous forme graphique.
Pour modifier les options de compilation, vous pouvez modifier la variable FLAGS définie en haut du fichier Makefile. Utilisez la ligne (commentée) qui contient les options -pg et -O0, mais en supprimant l'option -pg.
Utilisez gprof ou callgrind et donnez les 3 fonctions qui occupent le plus le processeur pendant l'execution de sat sur le fichier CNF-files/queens-15-SAT.cnf (sans les options d'affichage supplémentaire).
1.1. "Watch lists"
Pour accélérer la fonction is_non_false
, il va falloir utiliser
des structures de données supplémentaires.
Une formule en FNC est fausse lorsqu'une de ses clauses ne contient que des littéraux faux. Tant que toutes les clauses contiennent au moins un littéral non faux (càd vrai ou non-instancié), on peut essayer d'étendre la solution courante.
Le point de départ de l'optimisation qui va suivre est le suivant :
La formule peut devenir fausse uniquement lorsque le dernier littéral d'une clause devient faux. |
Les "watch lists" (listes de surveillance en français) vont donner, pour chaque littéral, une liste de clauses à surveiller. Par exemple, si la "watch list" du littéral ¬x3 contient (2), (7) et (9), cela signifie que lorsque ¬x3 devient faux, il suffit de vérifier que les clauses (2), (7) et (9) ne sont pas devenue fausses. Au lieu de parcourir toute la formule, on ne regarde donc que 3 clauses.
Initialement, chaque clause est surveillée par son premier littéral. Par exemple, pour la formule (x1 ∨ ¬x2 ∨ x3) ∧ (¬x1 ∨ ¬x3 ∨ x4) ∧ (x2 ∨ ¬x4) ∧ (x2 ∨ ¬x1), les "watch lists" sont :
-
littéral x1 : "clause (0)" (un seul élément),
-
littéral ¬x1 : "clause (1)" (un seul élément),
-
littéral x2 : "clause (2), clause (3)" (deux éléments),
-
littéraux ¬x2, x3, ¬x3, x4 et ¬x4 : listes vides.
Initialement, les "watch lists" vérifient les propriétés suivantes :
-
chaque clause apparait dans exactement une "watch list",
-
cette "watch list" est celle du premier littéral de la clause.
-
ce littéral n'est pas faux (il est non instancié).
Si l'algorithme décide de passer la variable x2 à FAUX, la
fonction is_non_false
n'aura besoin de regarder que les clauses
(2) et (3). Pour ces clauses :
-
il faut chercher un autre littéral dont la valeur n'est pas FAUX,
-
mettre les "watch lists" à jours.
Pour cette raison, la fonction is_not_false
s'appellera
désormais update_watch_lists
.
Par exemple, lorsque x2 devient FAUX, la clause (2)
(x2 ∨ ¬x4) ne peut plus apparaitre dans la "watch
list" du littéral x2. La fonction
update_watch_lists
devra donc :
-
échanger x2 et ¬x4 dans la clause,
-
insérer la clause (2) dans la watch list du littéral ¬x4.
Lorsque ceci aura été fait pour les clauses 2 et 3, il faudra également vider la "watch list" du littéral x2. Après ceci, l'invariant
-
Chaque clause apparait dans exactement une "watch list",
-
cette "watch list" est celle du premier littéral de la clause.
-
ce littéral n'est pas faux.
sera à nouveau vérifié.
La formule est fausse lorsque cette mise à jours n'est pas possible, c'est à dire lorsqu'on ne trouve pas de nouveau littéral non faux à mettre en tête de clause.
La fonction update_watch_list
est fournie dans le fichier solve-NOM.c, mais elle utilise une fonction
intermédiaire.
Écrivez la fonction new_watching_literal(formula_t* F, sol_t* S, int
cl)
qui recherche un nouveau littéral pour remplacer le premier
littéral de la clause cl
. La fonction devra parcourir la clause
cl
et renvoyer l'indice du premier littéral non faux de cette
clause. Si ce n'est pas possible, la fonction renvoie -1
.
(Attention à ne pas renvoyer l'indice du premier littéral car c'est celui que l'on souhaite remplacer !)
Données importantes :
-
tableaux
F->Cl
F->Lit
etS->State
-
macros
VARIABLE
etSIGN
Le corps de ma fonction fait une demi-douzaine de lignes.
Un point fondamental (mais non nécessaire pour la suite du TP) est le suivant : le backtracking est facile avec une fonction récursive, car la récursion sauvegarde les valeurs successives des données. Lors d'un retour en arrière, c'est la donnée correspondant à cet étape qui est donc utilisée automatiquement.
Lors d'un backtracking itératif, c'est au programmeur de gérer ceci.
L'avantage est qu'il n'est pas obligé de sauvegarder toutes les valeurs
successives, mais il peut modifier une valeur au fur et à mesure. C'est ce que
fait la fonction backtrack
sur les solutions.
La structure de données des "watch lists" a la propriété très intéressante de ne pas nécessiter de modification lors du backtracking ! Même si sa valeur n'est pas exactement la même, elle est équivalente. Ceci évite donc de complexifier le code et fait gagner en efficacité.
Exemples très simples
Vous devriez maintenant pouvoir tester :
Testez le solveur sur le fichier test-002.cnf et vérifiez que vous comprenez l'évolution des "watch lists".
$ ./sat -Wvvvv CNF-files/test-002.cnf
Benchmarks
Comparez les vitesses d'exécution de la version naïve et de la version avec "watch lists" sur les fichiers
$ ./sat -N CNF-files/queens-15-SAT.cnf
...
$ ./sat -W CNF-files/queens-15-SAT.cnf
...
Remarque : vous pouvez utiliser la commande time pour chronométrer le temps d'exécution de votre commande :
$ time ./sat -N CNF-files/queens-15-SAT.cnf
...
$ time ./sat -W CNF-files/queens-15-SAT.cnf
...
Pour observer l'impact des options de compilation, vous pouvez faire d'autres tests :
-
remplacer l'option -O4 par -O0 pour supprimer les optimisations du compilateur, et recompiler
-
ajouter l'option -NDEBUG et recompiler. (Faites une recherche pour découvrir à quoi correspond cette option !)
Comparez les vitesses d'exécution de la version C et de la version DPLL récursive écrite en Python sur les fichiers
$ time ./sat -W CNF-files/queens-15-SAT.cnf
...
$ time python3 ./naivesat.py -D CNF-files/queens-15-SAT.cnf
...
Testez ensuite sur le fichier
Décrivez ce que vous observez.
Les solutions trouvées ne seront pas forcément identiques, mais le résultat SATISFIABLE ou UNSATISFIABLE doit être identique.
Pour les 3 fichiers mentionnés ici, la formule correspondante est satisfiable.
1.2. Liste des variables actives
L'objectif final est d'obtenir une version de l'algorithme qui détecte les clauses unitaires (celles avec un unique littéral). Pour éviter de parcourir toutes les clauses à la recherche d'une clause unitaire, nous allons utiliser une autre liste : celle des variables qui apparaissent au début d'une clause. En effet, s'il y a une clause unitaire, la variable correspondante apparait nécessairement en tête d'une clause.
Si la formule ressemble à
(x1 ∨ x2 ∨ ...) | ∧ |
(¬x2) | ∧ |
(x2 ∨ x17) | ∧ |
(x1 ∨ ...) |
x17 ne peut pas apparaitre dans une clause unitaire car elle a des "watch lists" vides. Seules les variables x1 et x2 peuvent apparaitre dans une clause unitaire.
Dans ce cas là, on pourra donc instancier x2 à FAUX (rendant ¬x2 VRAI). La formule deviendra alors
(x1 ∨ ...) | ∧ |
(la clause unitaire disparait) | |
(x17) | ∧ |
(x1 ∨ ...) |
et x17 pourra maintenant être considérée car elle a une "watch list" non vide.
La liste des variables actives (A
) est initialisée par le code
fourni, à condition que l'on lance le programme avec l'option -A (ou -D).
Il faut par contre gérer les mises à jours de cette liste :
-
Lors du choix d'une valeur pour une variable (fonction
solve
), il faut supprimer la variable de la liste des variables actives.-
Pour cela, comme la variable choisie est la première variable active, il suffit de supprimer la première variable active (fonction
pop_active
). -
Attention, il ne faut pas faire ceci lorsque la variable en question est déjà instanciée. En effet, dans ce cas, la variable a déjà été retirée des variables actives à une étape précédente. (Cette situation arrive en cas de backtracking.)
-
-
Lors de la mise à jours des "watch lists" (fonction
update_watch_lists
), la nouvelle variable qui vient en tête d'une clause peut devenir active (fonctionpush_active
). Il faut :-
que cette variable soit à l'état
UNSET
(une variable instanciée ne peut pas être active), -
que cette variable ait des "watch list" vides (avec une "watch list" non vide, elle était déjà active).
-
-
Lors du backtracking (fonction
backtrack
), lorsqu'une variable est enlevée de la solution courante, son état devientUNSET
. Si elle a une "watch list" non vide, elle doit redevenir active (fonctionpush_active
).
Il n'est pas nécessaire de comprendre comment sont implémentées les "watch
lists", mais il faut pouvoir vérifier si elles sont vides. La structure
W
contient ces "watch lists", et en particulier, un champs
W->Head
.
On peut obtenir la (tête de la) "watch list"
-
du littéral xk avec
W->Head[k][1]
-
du littéral ¬xk avec
W->Head[k][0]
.
Autrement dit, W->Head
est un tableau à deux dimensions : le
premier indice est le signe du littéral, et le second le numéro de la
variable.
Une telle liste peut être vide (valeur EOL
), ou pas.
Le code fournit contient 3 TODO
dans les fonctions
solve
, update_watch_lists
et backtrack
.
Complétez ces fonctions en utilisant les remarques ci dessus et les
commentaires qui précèdent chacun des TODO
.
Données importantes :
-
tableau
W->Head[x][s]
qui donne la tête de la "watch list" du littéral correspondant à la variablex
et au signes
, -
tableau
S->State
qui donne l'état d'une variable.
Dans mon code, chacun des TODO
correspond à quelques lignes de
C.
Normalement, lorsqu'on supprime la variable active de tête (avec
pop_active
), cette variable de tête est égale à la variable
courante (current_var
).
Utilisez la fonction assert
du C pour vérifier ceci.
-
La fonction
pop_active
renvoie la variable de tête (celle qui est supprimée de la liste). -
La fonction
assert
prend un argument booléen et stoppe l'exécution lorsque cet argument est faux. Lorsqu'il est vrai, elle ne fait rien !Ce type d'assertions est très utile pour détecter des bugs lors du développement.
2. Algorithme DPLL, avec "watch lists" et variables actives
Tout est maintenant en place pour ajouter la simplification des clauses unitaires :
-
pour choisir une variable, on parcourt la liste des variables actives, et pour chacune d'entre elles, on cherche une clause unitaire dans ses "watch lists"
-
si on trouve une telle clause, on force la valeur de sa première variable (état
FORCED_FALSE
ouFORCED_TRUE
), -
si on n'en trouve pas, on prend la première variable active
-
et c'est tout !
La fonction backtrack
fournie gère déjà les états
FORCED_FALSE
et FORCED_TRUE
.
Vérifiez que votre code (dans la fonction solve
) qui supprime la
variable courante de la liste des variables actives gère correctement les
états FORCED_FALSE
(4
) et FORCED_TRUE
(5
) : il faut supprimer la tête des variables actives lorsque
current_var
a un état FALSE
, TRUE
,
FORCED_FALSE
ou FORCED_TRUE
.
2.1. Recherche des clauses unitaires
Écrivez la fonction is_unit(formula_t* F, sol_t* S, int cl)
qui
teste si la clause cl
est une clause unitaire : elle doit donc
parcourir les littéraux de cette clause et vérifier qu'ils sont tous faux,
sauf le premier (qui est UNSET
).
Données importantes :
-
tableaux
F->Cl
etS->State
, -
macros
VARIABLE)()
etSIGN()
, -
obtenir la valeur booléenne d'un état en prenant son bit de poids faible.
Mon code fait 6 lignes, dont 2 return
et 2 }
!
Ce n'est pas très important pour comprendre le TP, mais la liste des variables active est circulaire.
Lors de la recherche d'une clause unitaire, l'algorithme se déplace dans la liste jusqu'à trouver une telle clause (ou à faire un tour complet). Ceci lui permet de reprendre la recherche là où il s'était arrêté. (S'il n'y avait pas de clause unitaire au début, il n'est pas forcément nécessaire de reprendre la recherche au début !)
2.2. Quelques tests
Le répertoire CNF-files contient de nombreux fichiers, et en particulier des fichiers "de plus en plus gros" :
-
les fichiers CNF-files/queens-N-SAT.cnf (pour plusieurs valeurs de N) contiennent l'encodage en formule CNF du problème des N reines,
-
les fichiers CNF-files/multiplication-B-N.cnf contiennent l'encodage de la multiplication de 2 nombres de B bits pour obtenir un résultat de N,
-
les fichiers CNF-files/sudoku-easy-N.cnf et CNF-files/sudoku-hard-N.cnf contiennent l'encodage de grilles de sudoku classées "facile" ou "difficile".
Faites des tests d'efficacité et présentez vos résultats.
Vous pouvez par exemple comparer les différentes versions du programme en C (-N, -W, -A et -D), en incluant éventuellement les versions en Python.
Vous pouvez aussi expérimenter avec l'option -P (à utiliser en conjonction avec les versions -A et -D), qui "preprocess" la formule en supprimant les clauses unitaires avant de lancer la recherche.
N'hésitez pas à générer d'autres exemples avec les scripts du TP1.
Je vous conseille de recompiler le code avec l'option -DNDEBUG pour supprimer la vérification des assertions.
Vous pouvez aussi utiliser minisat (si vous l'avez installé). Pour l'utiliser, il suffit d'entrer la commande
$ minisat FICHIER.cnf
(Si vous souhaitez obtenir la solution, il faut donner un autre fichier dans lequel minisat écrira la solution.)
Vous pouvez également utiliser le générateur "Tough SAT Project", par exemple pour générer des fichiers aléatoires ("random k-Sat").
Pour faire vos tests, les commandes suivantes peuvent vous faciliter la tâche :
-
la commande time, qui affiche le temps utilisé pour terminer une commande. Vous pouvez utiliser l'option -p pour avoir un affichage plus succinct :
$ time -p ./sat FICHIER.cnf
-
la commande timeout, qui stoppe l'execution d'une commande après un intervalle de temps :
$ timeout 3m ./sat FICHIER.cnf
lancera la commande ./sat FICHIER.cnf. Si au bout de 3 minutes ("3m"), la commande n'est pas terminée, elle sera stoppée.
À l'aide du profiling, étudiez quels seraient les parties de l'algorithme à optimiser pour améliorer l'efficacité de notre programme.