Files
Kevin Lenzo 8a421961fb Fail fast on parallel comp_feat shards and diagnose CI gaps.
WaitForScript exits on nonzero child status; POSIX comp_feat jobs get unique
qmanager names per part/warp. Add diagnose_comp_feat.sh and run it on CI
failure for train-parallel and train-g2p-lda-vtln.
2026-06-04 14:29:29 -04:00

774 lines
21 KiB
Perl

# -*- cperl -*-
## ====================================================================
##
## Copyright (c) 2006 Carnegie Mellon University. All rights
## reserved.
##
## Redistribution and use in source and binary forms, with or without
## modification, are permitted provided that the following conditions
## are met:
##
## 1. Redistributions of source code must retain the above copyright
## notice, this list of conditions and the following disclaimer.
##
## 2. Redistributions in binary form must reproduce the above copyright
## notice, this list of conditions and the following disclaimer in
## the documentation and/or other materials provided with the
## distribution.
##
## This work was supported in part by funding from the Defense Advanced
## Research Projects Agency and the National Science Foundation of the
## United States of America, and the CMU Sphinx Speech Consortium.
##
## THIS SOFTWARE IS PROVIDED BY CARNEGIE MELLON UNIVERSITY ``AS IS'' AND
## ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
## THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
## PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CARNEGIE MELLON UNIVERSITY
## NOR ITS EMPLOYEES BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
## SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
## LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
## DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
## THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
## (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
## OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
##
## ====================================================================
##
## Author: David Huggins-Daines
##
use strict;
package SphinxTrain::Util;
require Exporter;
use vars qw(@ISA @EXPORT);
@ISA=qw(Exporter);
@EXPORT=qw(DateStr CreateHeader Log HTML_Print FormatURL
ImgSrc LogWarning LogError LogProgress
LogStatus Converged RunTool SubstParams
RunScript LaunchScript WaitForScript GetLists GetDict
WaitForConvergence TiedWaitForConvergence WaitForMMIEConverge Trim
MultipronTranscriptFile ShouldUseMultipronTranscript);
use Sys::Hostname;
use File::Basename;
use File::Spec;
use Queue;
die "Configuration (e.g. ./etc/sphinx_train.cfg) not defined\n" unless defined ($ST::CFG_DONE);
$ST::CFG_OKAY_COLOR = '00D000';
$ST::CFG_WARNING_COLOR = '555500';
$ST::CFG_ERROR_COLOR = 'DD0000';
sub DateStr
{
my (@time) = localtime(time());
my ($day_str) = sprintf ("%4d-%02d-%02d %02d:%02d",(1900 + $time[5]), ($time[4]+1),$time[3],$time[2],$time[1]);
}
sub CreateHeader {
my $logfile = shift;
my $hostname = hostname();
open HTML_LOG,">$logfile";
print HTML_LOG <<"EOH";
<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML//EN\">
<html>
<head>
<meta http-equiv="REFRESH" content="60">
<title>$ST::CFG_EXPTNAME</title>
<style type="text/css">
body {
background: #f0f0f5;
}
p,span {
font-family: monospace;
}
p {
margin-right: 6em;
}
p.phase {
font-weight: bold;
margin-bottom: 0;
}
p.result, p.WARNING, p.error {
margin-top: 0;
margin-bottom: 0;
margin-left: 5em;
}
span.status {
float: right;
margin-top: -1em;
}
.passed, .completed {
color: #$ST::CFG_OKAY_COLOR;
}
.WARNING, .warning {
color: #$ST::CFG_WARNING_COLOR;
}
.FAILED, .error {
color: #$ST::CFG_ERROR_COLOR;
}
</style>
</head>
<body>
<h1 class="experiment">${ST::CFG_BASE_DIR}</h1>
<h2 class="hostname">Training ${ST::CFG_EXPTNAME} on $hostname</h2>
EOH
close HTML_LOG;
}
sub Log {
my ($message, $class) = @_;
my (@time) = localtime(time());
my ($day_str) = sprintf ("%4d-%02d-%02d %02d:%02d",(1900 + $time[5]), ($time[4]+1),$time[3],$time[2],$time[1]);
my $logfile = "$ST::CFG_BASE_DIR/$ST::CFG_EXPTNAME.html";
chomp($message);
if (!defined($class) and $message =~ /^Phase/) {
$class = 'phase';
}
if ($class eq 'phase') {
print " $message\n" if $ST::CFG_VERBOSE;
}
elsif ($class eq 'result') {
print " $message\n" if $ST::CFG_VERBOSE;
}
else {
print "$message\n" if $ST::CFG_VERBOSE;
}
if (!-s $logfile) {
CreateHeader($logfile);
}
open HTML_LOG,">>$logfile";
if ($message =~ m/^(MODULE:).*/) {
print HTML_LOG "<hr>\n";
chomp ($message);
print HTML_LOG "<h3>$message\t($day_str)</h3>\n"; # Put Date information on Module headers
} else {
if (defined($class)) {
print HTML_LOG "<p class='$class'>$message</p>\n";
}
else {
print HTML_LOG "<p>$message</p>\n";
}
}
close HTML_LOG;
}
sub LogProgress {
my $message = shift;
my $logfile = "$ST::CFG_BASE_DIR/$ST::CFG_EXPTNAME.html";
open HTML_LOG,">>$logfile";
print "$message" if $ST::CFG_VERBOSE;
print HTML_LOG "<span class='progress'>$message</span>\n";
close HTML_LOG;
}
sub LogStatus {
my $status = shift;
my $logfile = "$ST::CFG_BASE_DIR/$ST::CFG_EXPTNAME.html";
if (!-s $logfile) {
CreateHeader($logfile);
}
open HTML_LOG,">>$logfile";
print HTML_LOG "<span class='status $status'>$status</span>\n";
close HTML_LOG;
}
sub HTML_Print
{
my $message = shift;
my $logfile = "$ST::CFG_BASE_DIR/$ST::CFG_EXPTNAME.html";
if (! -s $logfile) {
CreateHeader($logfile);
}
open HTML_LOG,">>$logfile";
print HTML_LOG "$message";
close HTML_LOG;
}
sub FormatURL
{
my $path = shift;
my $label = shift;
my $url;
if ($path =~ m/^\/.*/) {
$url = "file://";
} else {
$url = "file:///";
}
$url = "<a href=\"" . $url . "$path\">$label</a>";
return $url;
}
sub ImgSrc
{
my $path = shift;
my $url;
if ($path =~ m/^\/.*/) {
$url = "file://";
} else {
$url = "file:///";
}
$url = "<img src=\"" . $url . "$path\">";
return $url;
}
sub LogWarning
{
my $message = shift;
my (@time) = localtime(time());
my ($day_str) = sprintf ("%4d-%02d-%02d %02d:%02d",(1900 + $time[5]), ($time[4]+1),$time[3],$time[2],$time[1]);
my $logfile = "$ST::CFG_BASE_DIR/$ST::CFG_EXPTNAME.html";
print "WARNING: $message\n" if $ST::CFG_VERBOSE;
if (! -s $logfile) {
CreateHeader($logfile);
}
open HTML_LOG,">>$logfile";
print HTML_LOG "<p class='warning'>WARNING: $message</p>\n";
close HTML_LOG;
}
sub LogError
{
my $message = shift;
my (@time) = localtime(time());
my ($day_str) = sprintf ("%4d-%02d-%02d %02d:%02d",(1900 + $time[5]), ($time[4]+1),$time[3],$time[2],$time[1]);
my $logfile = "$ST::CFG_BASE_DIR/$ST::CFG_EXPTNAME.html";
chomp ($message); # remove \n if it exists
print "ERROR: $message\n" if $ST::CFG_VERBOSE;
if (! -s $logfile) {
CreateHeader($logfile);
}
open HTML_LOG,">>$logfile";
print HTML_LOG "<p class='error'>ERROR: $message</p>\n";
close HTML_LOG;
}
sub Converged
{
my $iter = shift;
my $logdir = shift;
my ($l,$p,$ratio);
# See what happened at this iteration
$l = `grep \"overall>\" $logdir/${ST::CFG_EXPTNAME}.${iter}-*.bw.log | awk '{X += \$3;Y += \$6} END {print Y/X}'`;
if ($iter > 1) {
my $tmp_iter = $iter - 1;
# See what happened last iteration
$p = `grep \"overall>\" $logdir/${ST::CFG_EXPTNAME}.${tmp_iter}-*.bw.log | awk '{X += \$3;Y += \$6} END {print Y/X}'`;
# Compute it's ratio
$ratio = ($l-$p)/abs($p);
Log("Ratio: $ratio", 'result');
}
# Don't even bother checking convergence until we've reached a minimum number of loops
return 0 if ($iter < $ST::CFG_MIN_ITERATIONS);
return 1 if ($ratio < $ST::CFG_CONVERGENCE_RATIO);
return 2 if ($iter > $ST::CFG_MAX_ITERATIONS);
}
sub RunTool {
my ($cmd, $logfile, $ctl_counter, @args) = @_;
HTML_Print("<p class='result'>$cmd " . FormatURL($logfile, "Log File") . "</p>");
unless ($cmd =~ / / or File::Spec->file_name_is_absolute($cmd)) {
# TODO: Handle architecture-specific directories here
my $fcmd = File::Spec->catfile($ST::CFG_BIN_DIR, $cmd);
# Windows needs the .exe suffix, but all OSes need to verify if
# the file exists. The right test here would be "-x", but this
# doesn't do anything useful in Windows. We use the next best
# thing, "-e" (but make sure it isn't a directory...)
foreach ("", ".exe") {
if (-e "$fcmd$_" and not -d "$fcmd$_") {
$cmd = "$fcmd$_";
last;
}
}
}
local (*LOG, $_);
open LOG,">$logfile";
local $| = 1; # Turn on autoflushing
# In case we're running with the PBS queue, print some additional
# information, useful if there's a need to debug
if ($ENV{PBS_ENVIRONMENT}) {
print LOG "This job was submitted by user: $ENV{PBS_O_LOGNAME}\n";
print LOG "This job was submitted to host: $ENV{PBS_O_HOST}\n";
print LOG "This job was submitted to queue: $ENV{PBS_O_QUEUE}\n";
print LOG "PBS working directory: $ENV{PBS_O_WORKDIR}\n";
print LOG "PBS job id: $ENV{PBS_JOBID}\n";
print LOG "PBS job name: $ENV{PBS_JOBNAME}\n";
print LOG "PBS environment: $ENV{PBS_ENVIRONMENT}\n";
my $hostname = hostname();
print LOG "This job is running on $hostname\n\n";
}
my $returnvalue = 0;
my $error_count = 0;
my $warning_count = 0;
my $processed_counter = 0;
my $printed = 0;
my ($pid, $pipe);
if ($^O eq 'MSWin32') {
# Win32 can't do -|, so quote all the arguments and do a simple
# pipe open.
foreach (@args) {
$_ = qq{"$_"};
}
$pid = open $pipe, "\"$cmd\" @args 2>&1 |";
}
else {
$pid = open $pipe, "-|";
die "Failed to open pipe: $!" unless defined($pid);
}
if ($ctl_counter) {
print " ";
}
if ($pid == 0) {
open STDERR, ">&STDOUT";
exec $cmd, @args or print STDERR "ERROR: Cannot exec '$cmd': $!\n";
}
else {
while (<$pipe>) {
print LOG "$_";
if (/^FATAL:.*line \d+/) {
LogError($_);
$returnvalue = 1;
last;
}
# Match Sphinx-style log lines only. A bare /ERROR/ false-positives on words like
# "TERROR" in transcripts; real messages are "ERROR: ..." (see sphinxbase err_msg).
$error_count++ if m/^\s*ERROR:/;
$warning_count++ if m/^\s*WARN(?:ING)?:/;
if ($ctl_counter) {
# Keep track of progress being made.
$processed_counter++ if (/.*(utt\>).*/);
my $percentage = int (($processed_counter / $ctl_counter) * 100);
if (!($percentage % 10)) {
print "${percentage}% " unless $printed;
$printed = 1;
} else {
$printed = 0;
}
}
}
unless (close $pipe) {
if ($!) {
$returnvalue = $!;
LogError("Exec failed: $!");
}
else {
$returnvalue = $? >> 8;
LogError("Subprocess failed with return code $returnvalue");
}
}
print "\n" if $ctl_counter;
if ($error_count > 0) {
LogError("This step had $error_count ERROR messages and " .
"$warning_count WARNING messages. " .
"Please check the log file for details.");
}
elsif ($warning_count > 0) {
LogWarning("This step had $error_count ERROR messages and " .
"$warning_count WARNING messages. " .
"Please check the log file for details.");
}
}
my $date = localtime;
print LOG "$date\n";
close LOG;
if ($returnvalue) {
LogStatus('FAILED');
} else {
LogStatus('completed');
}
return ($returnvalue);
}
sub RunScript {
my ($script, @args) = @_;
my $scriptdir = File::Spec->catdir($ST::CFG_SCRIPT_DIR,
basename(dirname($0)));
unless (File::Spec->file_name_is_absolute($script)) {
$script = File::Spec->catfile($scriptdir, $script);
}
unshift(@args, -cfg => $ST::CFG_FILE);
return system('perl', $script, @args);
}
sub LaunchScript {
my ($name, $cmd, $deps) = @_;
my $scriptdir = File::Spec->catdir($ST::CFG_SCRIPT_DIR,
basename(dirname($0)));
unless (defined($ST::Q)) {
$ST::CFG_QUEUE_TYPE = "Queue" unless defined $ST::CFG_QUEUE_TYPE;
eval "require $ST::CFG_QUEUE_TYPE"
or die "Failed to load queue $ST::CFG_QUEUE_TYPE: $@";
$ST::Q = $ST::CFG_QUEUE_TYPE->new();
}
my ($script, @args);
if (ref($cmd)) {
($script, @args) = @$cmd;
}
else {
$script = $cmd;
}
unless (File::Spec->file_name_is_absolute($script)) {
$script = File::Spec->catfile($scriptdir, $script);
}
unshift(@args, -cfg => File::Spec->rel2abs($ST::CFG_FILE));
# Capture output somewhere for background queues
my @qmanager;
if ($ST::CFG_QUEUE_TYPE ne 'Queue') {
my $bn = basename($script);
mkdir $ST::CFG_QMGR_DIR, 0777 unless -d $ST::CFG_QMGR_DIR;
push @qmanager, errfile => File::Spec->catfile($ST::CFG_QMGR_DIR, "$name.err");
push @qmanager, outfile => File::Spec->catfile($ST::CFG_QMGR_DIR, "$name.out");
}
return $ST::Q->submit_job({deps => $deps,
name => $name,
@qmanager,
command => ['perl', $script, @args]});
}
sub WaitForScript {
foreach my $id (@_) {
my $exit = 0;
if (defined($ST::CFG_QUEUE_TYPE) && $ST::CFG_QUEUE_TYPE eq 'Queue') {
# Synchronous Queue: $id is wait status from system(), not a pid.
$exit = $id >> 8;
}
else {
$ST::Q->waitfor_job($id);
$exit = $? >> 8;
}
if ($exit != 0) {
LogError("Parallel job failed with exit code $exit\n");
exit $exit;
}
}
}
sub WaitForConvergence {
my $logdir = shift;
my $interval = shift;
$interval = 5 unless defined($interval);
# Wait for training to complete (FIXME: This needs to be more robust)
my $maxiter = 0;
while (1) {
my $iter;
my $likeli;
for ($iter = 1; $iter <= $ST::CFG_MAX_ITERATIONS; ++$iter) {
my $norm_log = File::Spec->catfile($logdir, "$ST::CFG_EXPTNAME.$iter.norm.log");
if (! -f $norm_log) { # Special case for CI training
$norm_log = File::Spec->catfile($logdir, "$ST::CFG_EXPTNAME.$ST::CFG_INITIAL_NUM_DENSITIES.$iter.norm.log");
}
open LOG, "<$norm_log" or last;
while (<LOG>) {
if (/failed/i or /Aborting/) {
LogError("Training failed in iteration $iter");
return -1;
}
elsif (/COMPLETE/) {
Log("Training completed after $iter iterations", 'result');
return 0;
}
elsif (/Likelihood Per Frame = (\S+)/) {
$likeli = $1;
}
}
close LOG;
}
--$iter;
if ($iter > $maxiter) {
print "Baum-Welch iteration $iter Average log-likelihood $likeli\n";
$maxiter = $iter;
}
sleep $interval;
}
return 0;
}
sub TiedWaitForConvergence {
my $logdir = shift;
my $interval = shift;
$interval = 5 unless defined($interval);
# Wait for training to complete (FIXME: This needs to be more robust)
my ($maxngau, $maxiter, $lastngau, $lastiter) = (0,0,0,0);
while (1) {
my ($ngau, $iter, $likeli);
$ngau = $ST::CFG_INITIAL_NUM_DENSITIES;
NGAU:
while ($ngau <= $ST::CFG_FINAL_NUM_DENSITIES) {
ITER:
for ($iter = 1; $iter <= $ST::CFG_MAX_ITERATIONS; ++$iter) {
my $norm_log = File::Spec->catfile($logdir,
"$ST::CFG_EXPTNAME.$ngau.$iter.norm.log");
open LOG, "<$norm_log" or last ITER;
while (<LOG>) {
if (/failed/i or /Aborting/) {
Log("Training failed in iteration $iter");
return -1;
} elsif (/COMPLETE/) {
Log("Training for $ngau Gaussian(s) completed after $iter iterations");
return 0;
}
elsif (/Likelihood Per Frame = (\S+)/) {
$likeli = $1;
}
}
close LOG;
}
--$iter;
if ($iter != 0 and $ngau > $maxngau) {
$maxngau = $ngau;
}
if ($iter > 0) {
$maxiter = $iter;
}
if ($iter == 0 or $ngau == $ST::CFG_FINAL_NUM_DENSITIES) {
last NGAU;
}
else {
$ngau *= 2;
if ($ngau > $ST::CFG_FINAL_NUM_DENSITIES) {
$ngau = $ST::CFG_FINAL_NUM_DENSITIES;
}
}
}
if ($maxngau > $lastngau or $maxiter > $lastiter) {
print "Baum-Welch gaussians $maxngau iteration $maxiter Average log-likelihood $likeli\n";
$lastngau = $maxngau;
$lastiter = $maxiter;
}
sleep $interval;
}
return 0;
}
sub WaitForMMIEConverge {
my $logdir = shift;
my $interval = shift;
$interval = 5 unless defined($interval);
# Wait for training to complete (FIXME: This needs to be more robust)
my $maxiter = 0;
while (1) {
my $iter;
my $postprob;
for ($iter = 1; $iter <= $ST::CFG_MAX_ITERATIONS; ++$iter) {
my $norm_log = File::Spec->catfile($logdir, "$ST::CFG_EXPTNAME.$iter.norm.log");
open LOG, "<$norm_log" or last;
while (<LOG>) {
if (/failed/i or /Aborting/) {
LogError("Training failed in iteration $iter");
return -1;
}
elsif (/COMPLETE/) {
Log("Training completed after $iter iterations", 'result');
return 0;
}
elsif (/Log Posterior Probability Per Utterance = (\S+)/) {
$postprob = $1;
}
}
close LOG;
}
--$iter;
if ($iter > $maxiter) {
print "Baum-Welch iteration $iter Average Log Posterior Probability $postprob\n";
$maxiter = $iter;
}
sleep $interval;
}
return 0;
}
=head2 GetDict
Select and return the appropriate dictionary depending on predefined variables.
=cut
sub GetDict {
my $path = SphinxTrain::Resolved::derived("dictionary");
return $path if defined $path && $path ne "";
if ($ST::CFG_FORCE_ALIGN_SPD eq "yes") {
return File::Spec->catfile($ST::CFG_BASE_DIR, "falignout",
"${ST::CFG_EXPTNAME}.spdict");
}
if (defined $ST::CFG_G2P_MODEL && $ST::CFG_G2P_MODEL eq "yes") {
return "$ST::CFG_DICTIONARY.full";
}
return $ST::CFG_DICTIONARY;
}
sub MultipronTranscriptFile {
my $path = SphinxTrain::Resolved::derived("multipron_transcript");
return $path if defined $path && $path ne "";
return "$ST::CFG_BASE_DIR/multipron_align/${ST::CFG_EXPTNAME}.multipron.transcription";
}
# Multipron is on unless CFG_MULTIPRON is explicitly no. Use the multipron transcript
# only after stage 21 has produced the file (after CI when multipron is enabled).
sub ShouldUseMultipronTranscript {
if (defined $SphinxTrain::Resolved::DOC) {
return SphinxTrain::Resolved::derived("should_use_multipron_transcript");
}
return 0 unless defined($ST::CFG_MULTIPRON);
return 0 if $ST::CFG_MULTIPRON eq "no";
return -f MultipronTranscriptFile();
}
sub GetLists {
# aligned transcripts and the list of aligned files is obtained as a result
# of (03.) forced alignment or (04.) VTLN
my ($listoffiles, $transcriptfile);
if ($ST::CFG_VTLN eq "yes") {
$listoffiles = "$ST::CFG_BASE_DIR/vtlnout/${ST::CFG_EXPTNAME}.alignedfiles";
$transcriptfile = "$ST::CFG_BASE_DIR/vtlnout/${ST::CFG_EXPTNAME}.alignedtranscripts";
} elsif ($ST::CFG_FORCE_ALIGN_SPD eq "yes") {
$listoffiles = "$ST::CFG_BASE_DIR/falignout/${ST::CFG_EXPTNAME}.alignedfiles";
$transcriptfile = "$ST::CFG_BASE_DIR/falignout/${ST::CFG_EXPTNAME}.sptranscripts";
} elsif ($ST::CFG_FORCEDALIGN eq "yes") {
$listoffiles = "$ST::CFG_BASE_DIR/falignout/${ST::CFG_EXPTNAME}.alignedfiles";
$transcriptfile = "$ST::CFG_BASE_DIR/falignout/${ST::CFG_EXPTNAME}.alignedtranscripts";
} elsif (ShouldUseMultipronTranscript()) {
$listoffiles = SphinxTrain::Resolved::derived("train_listoffiles")
|| $ST::CFG_LISTOFFILES;
$transcriptfile = MultipronTranscriptFile();
} else {
$listoffiles = SphinxTrain::Resolved::derived("train_listoffiles")
|| $ST::CFG_LISTOFFILES;
$transcriptfile = SphinxTrain::Resolved::derived("train_transcript")
|| $ST::CFG_TRANSCRIPTFILE;
}
return ($listoffiles, $transcriptfile);
}
sub SubstParams {
my ($in, $out) = @_;
local (*IN, *OUT);
open IN, "<$in" or die "Failed to open $in: $!";
open OUT, ">$out" or die "Failed to open $out: $!";
no strict 'refs';
while (<IN>) {
# For the time being assume there's only one of these per line.
if (/__(\w+)__/) {
my $var = $1;
# Skip the line if the variable is not defined
if (defined(my $val = ${"ST::$var"})) {
s/__${var}__/$val/ge;
print OUT;
}
}
else {
print OUT;
}
}
close IN;
close OUT or die "Failed to close $out: $!";
}
# Perl trim function to remove whitespace from the start and end of the string
sub Trim($)
{
my $string = shift;
$string =~ s/^\s+//;
$string =~ s/\s+$//;
return $string;
}
1;
__END__
=head1 NAME
SphinxTrain::Util - Utility functions for Sphinx training
=head1 SYNOPSIS
use SphinxTrain::Util;
=head1 DESCRIPTION
This module exports various utility functions used by the Sphinx trainer.
=head1 EXPORTS
=over 4
=item DateStr
=item CreateHeader
=item Log
=item HTML_Print
=item FormatURL
=item ImgSrc
=item LogWarning
=item LogError
=item Converged
=item RunTool
=item RunScript
=item LaunchScript
=item WaitForScript
=item WaitForConvergence
=item TiedWaitForConvergence
=item SubstParams
=item Trim
=back
=head1 AUTHOR
David Huggins-Daines <dhuggins@cs.cmu.edu>
=cut