wincent / base16-nvim

base16 color schemes in Lua for Neovim
MIT License
26 stars 1 forks source link

Sync with improvements made in base16-project/base16-vim (fork) #1

Open wincent opened 3 years ago

wincent commented 3 years ago

See this chriskempson/base16-vim fork:

Diff: https://github.com/base16-project/base16-vim/compare/c8a7da6...HEAD

Examples of potentially interesting commits:

vinitkumar commented 3 years ago

Nice one @wincent. Sorry, if this question annoys you, it there a performance benefit of using this in place of the .vim color files for base-16 because of better treesitter integration here?

I just ported my neovim config to lua and the only thing remaining is the colorschemes.

wincent commented 3 years ago

@vinitkumar: Totally unscientific results here, but .vim version seems to take about 11ms on the machine I am testing on right now (mid-2015 15" MacBook Pro) vs Lua which takes just under half that.

Vimscript:

011.658  011.156: sourcing /Users/glh/.config/nvim/colors/base16-bright.vim

Lua:

005.091  004.453: sourcing /Users/glh/.config/nvim/colors/base16-bright.lua

Admittedly, saving almost 7ms is definitely micro-optimizing, but every little bit counts when you are trying to keep your Vim startup as snappy as possible.

The other reason is just for consistency; if I can replace 100% of the Vimscript with Lua, then there is a small chunk of esoteric Vimscript-specific knowledge (that is basically useless in any other context) that I can just mark for garbage collection from my brain. 😂

vinitkumar commented 3 years ago

Awesome. Thanks. It is good to use already?

wincent commented 3 years ago

Awesome. Thanks. It is good to use already?

I would say so @vinitkumar. I did a very literal port from Vimscript to Lua with no "clever stuff". This diff shows what changed in the template used to generate the schemes. As you can see, it is just mechanical stuff like:

" Vimscript
let s:gui00 = "{{base00-hex}}"

to:

-- lua
local gui00 = "{{base00-hex}}"

And this gist shows everything that changed in the built scheme files: basically, a bunch of new schemes got added, and some minor (legit) changes made by template/scheme authors got brought in (as a small example, look at the changes to the base16-horizon-dark theme).

vinitkumar commented 3 years ago

So, I use this simple Python script to profile the vim startup times (got this from github)

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# vim-profiler - Utility script to profile (n)vim (e.g. startup)
# Copyright © 2015 Benjamin Chrétien
# Copyright © 2017-2020 Hörmet Yiltiz <hyiltiz@github.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import print_function

import os
import sys
import subprocess
import re
import csv
import operator
import argparse
import collections

def to_list(cmd):
    if not isinstance(cmd, (list, tuple)):
        cmd = cmd.split(' ')
    return cmd

def get_exe(cmd):
    # FIXME: this assumes that the first word is the executable
    return to_list(cmd)[0]

def is_subdir(paths, subdir):
    # See: http://stackoverflow.com/a/18115684/1043187
    for path in paths:
        path = os.path.realpath(path)
        subdir = os.path.realpath(subdir)
        reldir = os.path.relpath(subdir, path)
        if not (reldir == os.pardir or reldir.startswith(os.pardir + os.sep)):
            return True
    return False

def stdev(arr):
    """
    Compute the standard deviation.
    """
    if sys.version_info >= (3, 0):
        import statistics
        return statistics.pstdev(arr)
    else:
        # Dependency on NumPy
        try:
            import numpy
            return numpy.std(arr, axis=0)
        except ImportError:
            return 0.

class StartupData(object):
    """
    Data for (n)vim startup (timings etc.).
    """
    def __init__(self, cmd, log_filename, check_system=False):
        super(StartupData, self).__init__()
        self.cmd = cmd
        self.log_filename = log_filename
        self.times = dict()
        self.system_dirs = ["/usr", "/usr/local"]
        self.generate(check_system)

    def generate(self, check_system=False):
        """
        Generate startup data.
        """
        self.__run_vim()
        try:
            self.__load_times(check_system)
        except RuntimeError:
            print("\nNo plugin found. Exiting.")
            sys.exit()

        if not self.times:
            sys.exit()

    def __guess_plugin_dir(self, log_txt):
        """
        Try to guess the vim directory containing plugins.
        """
        candidates = list()

        # Get common plugin dir if any
        vim_subdirs = "autoload|ftdetect|plugin|syntax"
        matches = re.findall("^\d+.\d+\s+\d+.\d+\s+\d+.\d+: "
                             "sourcing (.+?)/(?:[^/]+/)(?:%s)/[^/]+"
                             % vim_subdirs, log_txt, re.MULTILINE)
        for plugin_dir in matches:
            # Ignore system plugins
            if not is_subdir(self.system_dirs, plugin_dir):
                candidates.append(plugin_dir)

        if candidates:
            # FIXME: the directory containing vimrc could be returned as well
            return collections.Counter(candidates).most_common(1)[0][0]
        else:
            raise RuntimeError("no user plugin found")

    def __load_times(self, check_system=False):
        """
        Load startup times for log file.
        """
        # Load log file and process it
        print("Loading and processing logs...", end="")
        with open(self.log_filename, 'r') as log:
            log_txt = log.read()
            plugin_dir = ""

            # Try to guess the folder based on the logs themselves
            try:
                plugin_dir = self.__guess_plugin_dir(log_txt)
                matches = re.findall("^\d+.\d+\s+\d+.\d+\s+(\d+.\d+): "
                                     "sourcing %s/([^/]+)/" % plugin_dir,
                                     log_txt, re.MULTILINE)
                for res in matches:
                    time = res[0]
                    plugin = res[1]
                    if plugin in self.times:
                        self.times[plugin] += float(time)
                    else:
                        self.times[plugin] = float(time)
            # Catch exception if no plugin was found
            except RuntimeError as e:
                if not check_system:
                    raise
                else:
                    plugin_dir = ""

            if check_system:
                for d in self.system_dirs:
                    matches = re.findall("^\d+.\d+\s+\d+.\d+\s+(\d+.\d+): "
                                         "sourcing %s/.+/([^/]+.vim)\n" % d,
                                         log_txt, re.MULTILINE)
                    for res in matches:
                        time = res[0]
                        plugin = "*%s" % res[1]
                        if plugin in self.times:
                            self.times[plugin] += float(time)
                        else:
                            self.times[plugin] = float(time)

        print(" done.")
        if plugin_dir:
            print("Plugin directory: %s" % plugin_dir)
        else:
            print("No user plugin found.")
        if not self.times:
            print("No system plugin found.")

    def __run_vim(self):
        """
        Run vim/nvim to generate startup logs.
        """
        print("Running %s to generate startup logs..." % get_exe(self.cmd),
              end="")
        self.__clean_log()
        full_cmd = to_list(self.cmd) + ["--startuptime", self.log_filename,
                                        "-f", "-c", "q"]
        subprocess.call(full_cmd, shell=False)
        print(" done.")

    def __clean_log(self):
        """
        Clean log file.
        """
        if os.path.isfile(self.log_filename):
            os.remove(self.log_filename)

    def __del__(self):
        """
        Destructor taking care of clean up.
        """
        self.__clean_log()

class StartupAnalyzer(object):
    """
    Analyze startup times for (n)vim.
    """
    def __init__(self, param):
        super(StartupAnalyzer, self).__init__()
        self.runs = param.runs
        self.cmd = param.cmd
        self.raw_data = [StartupData(self.cmd, "vim_%i.log" % (i+1),
                                     check_system=param.check_system)
                         for i in range(self.runs)]
        self.data = self.process_data()

    def process_data(self):
        """
        Merge startup times for each plugin.
        """
        return {k: [d.times[k] for d in self.raw_data]
                for k in self.raw_data[0].times.keys()}

    def average_data(self):
        """
        Return average times for each plugin.
        """
        return {k: sum(v)/len(v) for k, v in self.data.items()}

    def stdev_data(self):
        """
        Return standard deviation for each plugin.
        """
        return {k: stdev(v) for k, v in self.data.items()}

    def plot(self):
        """
        Plot startup data.
        """
        import pylab

        print("Plotting result...", end="")
        avg_data = self.average_data()
        avg_data = self.__sort_data(avg_data, False)
        if len(self.raw_data) > 1:
            err = self.stdev_data()
            sorted_err = [err[k] for k in list(zip(*avg_data))[0]]
        else:
            sorted_err = None
        pylab.barh(range(len(avg_data)), list(zip(*avg_data))[1],
                   xerr=sorted_err, align='center', alpha=0.4)
        pylab.yticks(range(len(avg_data)), list(zip(*avg_data))[0])
        pylab.xlabel("Average startup time (ms)")
        pylab.ylabel("Plugins")
        pylab.show()
        print(" done.")

    def export(self, output_filename="result.csv"):
        """
        Write sorted result to file.
        """
        assert len(self.data) > 0
        print("Writing result to %s..." % output_filename, end="")
        with open(output_filename, 'w') as fp:
            writer = csv.writer(fp, delimiter='\t')
            # Compute average times
            avg_data = self.average_data()
            # Sort by average time
            for name, avg_time in self.__sort_data(avg_data):
                writer.writerow(["%.3f" % avg_time, name])
        print(" done.")

    def print_summary(self, n):
        """
        Print summary of startup times for plugins.
        """
        title = "Top %i plugins slowing %s's startup" % (n, get_exe(self.cmd))
        length = len(title)
        print(''.center(length, '='))
        print(title)
        print(''.center(length, '='))

        # Compute average times
        avg_data = self.average_data()
        # Sort by average time
        rank = 0
        for name, time in self.__sort_data(avg_data)[:n]:
            rank += 1
            print("%i\t%7.3f   %s" % (rank, time, name))

        print(''.center(length, '='))

    @staticmethod
    def __sort_data(d, reverse=True):
        """
        Sort data by decreasing time.
        """
        return sorted(d.items(), key=operator.itemgetter(1), reverse=reverse)

def main():
    parser = argparse.ArgumentParser(
            description='Analyze startup times of vim/neovim plugins.')
    parser.add_argument("-o", dest="csv", type=str,
                        help="Export result to a csv file")
    parser.add_argument("-p", dest="plot", action='store_true',
                        help="Plot result as a bar chart")
    parser.add_argument("-s", dest="check_system", action='store_true',
                        help="Consider system plugins as well (marked with *)")
    parser.add_argument("-n", dest="n", type=int, default=10,
                        help="Number of plugins to list in the summary")
    parser.add_argument("-r", dest="runs", type=int, default=1,
                        help="Number of runs (for average/standard deviation)")
    parser.add_argument(dest="cmd", nargs=argparse.REMAINDER, type=str, default="nvim",
                        help="vim/neovim executable or command")

    # Parse CLI arguments
    args = parser.parse_args()
    output_filename = args.csv
    n = args.n

    # Command (default = vim)
    if args.cmd == []:
        args.cmd = "nvim"

    # Run analysis
    analyzer = StartupAnalyzer(args)
    if n > 0:
        analyzer.print_summary(n)
    if output_filename is not None:
        analyzer.export(output_filename)
    if args.plot:
        analyzer.plot()

if __name__ == "__main__":
    main()

I used this to profile my nvim config after adding base16-vim. It still make it slowest because of the sheer amount of colorschemes in the plugin. It also happened and was worse with the vimscript version of base16-vim.

Is there some way, where I can just load say (base16-bright) into the memory and not all. That will help improve the startup time, especially with these themes with 100s of colorschemes.

Here are the results on my computer:

Running nvim to generate startup logs... done.
Loading and processing logs... done.
Plugin directory: /Users/vinitkumar/.local/share/nvim/site/pack/packer/start
=====================================
Top 10 plugins slowing nvim's startup
=====================================
1     8.358   base16-nvim
2     5.796   nvim-compe
3     5.522   vimwiki
4     1.988   vim-fugitive
5     0.893   nvim-treesitter
6     0.525   ack.vim
7     0.298   telescope.nvim
8     0.248   vim-commentary
9     0.109   nvim-lspconfig
10    0.050   plenary.nvim
=====================================
wincent commented 3 years ago

I don't think it's because of the number of files; I think it's because processing even one color scheme file is slow. ie: if you delete all the files except the one corresponding to the scheme you're using you'll still get the same timing, due to the number of :highlight calls.

I think to make it faster you'd need to trim out unused groups, or hope for a future faster nvim_ API for setting highlight groups.

wincent commented 2 years ago

Status update

chriskempson/base16-vim finally got some updates. I synced with those in these commits: 92ed876c7ded..a80c30343ab4.

Whether that project is fully or partially abandoned is unclear; in any case, the base16-project/base16-vim fork diverges farther and farther away (unfortunately, can't share a proper "compare" link because it's not an actual GitHub fork even though it is a Git fork — this comparison shows how far it has diverged from the branch point). So I'm going to keep monitoring and will have to eventually decide whether to selectively backport a few things or switch wholesale. This is a deep-link to the changes in the default.mustache template since the fork, and here is the actual diff between the two HEAD versions of that file, in a gist. Made that with:

git remote add base16-project git@github.com:base16-project/base16-vim.git
git fetch base16-project
git remote add base16-vim git@github.com:chriskempson/base16-vim.git
git fetch base16-vim
git rev-parse --short base16-vim/master   # To show the concrete hashes I'm diffing.
git rev-parse --short base16-project/main # To show the concrete hashes I'm diffing.
git diff c156b90..57bfe69 -- templates/default.mustache